cc-context-stats 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/scripts/context-stats.sh +1 -1
- package/.editorconfig +0 -60
- package/.eslintrc.json +0 -35
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
- package/.github/dependabot.yml +0 -44
- package/.github/workflows/ci.yml +0 -294
- package/.github/workflows/release.yml +0 -151
- package/.pre-commit-config.yaml +0 -74
- package/.prettierrc +0 -33
- package/.shellcheckrc +0 -10
- package/CHANGELOG.md +0 -187
- package/CLAUDE.md +0 -66
- package/CODE_OF_CONDUCT.md +0 -59
- package/CONTRIBUTING.md +0 -240
- package/RELEASE_NOTES.md +0 -19
- package/SECURITY.md +0 -44
- package/TODOS.md +0 -72
- package/assets/logo/favicon.svg +0 -19
- package/assets/logo/logo-black.svg +0 -24
- package/assets/logo/logo-full.svg +0 -40
- package/assets/logo/logo-icon.svg +0 -27
- package/assets/logo/logo-mark.svg +0 -28
- package/assets/logo/logo-white.svg +0 -24
- package/assets/logo/logo-wordmark.svg +0 -6
- package/config/settings-example.json +0 -7
- package/config/settings-node.json +0 -7
- package/config/settings-python.json +0 -7
- package/docs/ARCHITECTURE.md +0 -128
- package/docs/CSV_FORMAT.md +0 -42
- package/docs/DEPLOYMENT.md +0 -71
- package/docs/DEVELOPMENT.md +0 -161
- package/docs/MODEL_INTELLIGENCE.md +0 -396
- package/docs/configuration.md +0 -118
- package/docs/context-stats.md +0 -143
- package/docs/installation.md +0 -255
- package/docs/scripts.md +0 -140
- package/docs/troubleshooting.md +0 -278
- package/images/claude-statusline-token-graph.gif +0 -0
- package/images/claude-statusline.png +0 -0
- package/images/context-status-dumbzone.png +0 -0
- package/images/context-status.png +0 -0
- package/images/statusline-detail.png +0 -0
- package/images/token-graph.jpeg +0 -0
- package/images/token-graph.png +0 -0
- package/images/v1.6.1.png +0 -0
- package/install +0 -351
- package/install.sh +0 -298
- package/jest.config.js +0 -11
- package/pyproject.toml +0 -115
- package/requirements-dev.txt +0 -12
- package/scripts/statusline-full.sh +0 -438
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -569
- package/src/claude_statusline/__init__.py +0 -11
- package/src/claude_statusline/__main__.py +0 -6
- package/src/claude_statusline/cli/__init__.py +0 -1
- package/src/claude_statusline/cli/context_stats.py +0 -542
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -184
- package/src/claude_statusline/core/__init__.py +0 -1
- package/src/claude_statusline/core/colors.py +0 -124
- package/src/claude_statusline/core/config.py +0 -165
- package/src/claude_statusline/core/git.py +0 -78
- package/src/claude_statusline/core/state.py +0 -323
- package/src/claude_statusline/formatters/__init__.py +0 -1
- package/src/claude_statusline/formatters/layout.py +0 -67
- package/src/claude_statusline/formatters/time.py +0 -50
- package/src/claude_statusline/formatters/tokens.py +0 -70
- package/src/claude_statusline/graphs/__init__.py +0 -1
- package/src/claude_statusline/graphs/intelligence.py +0 -162
- package/src/claude_statusline/graphs/renderer.py +0 -401
- package/src/claude_statusline/graphs/statistics.py +0 -92
- package/src/claude_statusline/ui/__init__.py +0 -1
- package/src/claude_statusline/ui/icons.py +0 -93
- package/src/claude_statusline/ui/waiting.py +0 -62
- package/tests/bash/test_delta_parity.bats +0 -199
- package/tests/bash/test_install.bats +0 -29
- package/tests/bash/test_parity.bats +0 -315
- package/tests/bash/test_statusline_full.bats +0 -139
- package/tests/bash/test_statusline_git.bats +0 -42
- package/tests/bash/test_statusline_minimal.bats +0 -37
- package/tests/fixtures/json/comma_in_path.json +0 -31
- package/tests/fixtures/json/high_usage.json +0 -17
- package/tests/fixtures/json/low_usage.json +0 -17
- package/tests/fixtures/json/medium_usage.json +0 -17
- package/tests/fixtures/json/valid_full.json +0 -30
- package/tests/fixtures/json/valid_minimal.json +0 -9
- package/tests/fixtures/mi_test_vectors.json +0 -140
- package/tests/node/intelligence.test.js +0 -98
- package/tests/node/rotation.test.js +0 -89
- package/tests/node/statusline.test.js +0 -240
- package/tests/python/conftest.py +0 -84
- package/tests/python/test_colors.py +0 -105
- package/tests/python/test_config_colors.py +0 -78
- package/tests/python/test_data_pipeline.py +0 -446
- package/tests/python/test_explain.py +0 -177
- package/tests/python/test_icons.py +0 -152
- package/tests/python/test_intelligence.py +0 -314
- package/tests/python/test_layout.py +0 -127
- package/tests/python/test_state_rotation_validation.py +0 -232
- package/tests/python/test_statusline.py +0 -215
- package/tests/python/test_waiting.py +0 -127
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
"""Tests for layout helpers (visible_width, get_terminal_width, fit_to_width)."""
|
|
2
|
-
|
|
3
|
-
from unittest.mock import patch
|
|
4
|
-
|
|
5
|
-
from claude_statusline.formatters.layout import (
|
|
6
|
-
fit_to_width,
|
|
7
|
-
get_terminal_width,
|
|
8
|
-
visible_width,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestVisibleWidth:
|
|
13
|
-
"""Tests for visible_width()."""
|
|
14
|
-
|
|
15
|
-
def test_plain_text(self):
|
|
16
|
-
assert visible_width("hello") == 5
|
|
17
|
-
|
|
18
|
-
def test_ansi_colored_string(self):
|
|
19
|
-
s = "\033[0;32mgreen\033[0m"
|
|
20
|
-
assert visible_width(s) == 5
|
|
21
|
-
|
|
22
|
-
def test_multiple_ansi_codes(self):
|
|
23
|
-
s = "\033[2m[\033[0m\033[0;34mdir\033[0m"
|
|
24
|
-
assert visible_width(s) == 4 # [dir
|
|
25
|
-
|
|
26
|
-
def test_empty_string(self):
|
|
27
|
-
assert visible_width("") == 0
|
|
28
|
-
|
|
29
|
-
def test_unicode_icons(self):
|
|
30
|
-
assert visible_width("\u25cb") == 1 # ○
|
|
31
|
-
assert visible_width("\u26a1") == 1 # ⚡
|
|
32
|
-
|
|
33
|
-
def test_ansi_with_semicolons(self):
|
|
34
|
-
s = "\033[1;31;42mtext\033[0m"
|
|
35
|
-
assert visible_width(s) == 4
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class TestGetTerminalWidth:
|
|
39
|
-
"""Tests for get_terminal_width()."""
|
|
40
|
-
|
|
41
|
-
def test_fallback_to_200_when_no_columns_env(self):
|
|
42
|
-
"""When COLUMNS is not set and shutil returns 80, use 200 (Claude Code subprocess)."""
|
|
43
|
-
with (
|
|
44
|
-
patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
|
|
45
|
-
patch.dict("os.environ", {}, clear=False),
|
|
46
|
-
):
|
|
47
|
-
# Remove COLUMNS if present
|
|
48
|
-
import os
|
|
49
|
-
|
|
50
|
-
os.environ.pop("COLUMNS", None)
|
|
51
|
-
mock.return_value = type("Size", (), {"columns": 80})()
|
|
52
|
-
assert get_terminal_width() == 200
|
|
53
|
-
|
|
54
|
-
def test_respects_columns_env_80(self):
|
|
55
|
-
"""When COLUMNS=80 is explicitly set, trust it."""
|
|
56
|
-
with (
|
|
57
|
-
patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
|
|
58
|
-
patch.dict("os.environ", {"COLUMNS": "80"}),
|
|
59
|
-
):
|
|
60
|
-
mock.return_value = type("Size", (), {"columns": 80})()
|
|
61
|
-
assert get_terminal_width() == 80
|
|
62
|
-
|
|
63
|
-
def test_custom_width(self):
|
|
64
|
-
with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
|
|
65
|
-
mock.return_value = type("Size", (), {"columns": 120})()
|
|
66
|
-
assert get_terminal_width() == 120
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class TestFitToWidth:
|
|
70
|
-
"""Tests for fit_to_width()."""
|
|
71
|
-
|
|
72
|
-
def test_all_parts_fit(self):
|
|
73
|
-
parts = ["base", " | git", " | ctx"]
|
|
74
|
-
result = fit_to_width(parts, 80)
|
|
75
|
-
assert result == "base | git | ctx"
|
|
76
|
-
|
|
77
|
-
def test_drops_lowest_priority(self):
|
|
78
|
-
parts = ["base", " | git", " | ctx", " session-uuid-here"]
|
|
79
|
-
result = fit_to_width(parts, 25)
|
|
80
|
-
assert "base" in result
|
|
81
|
-
assert "session-uuid-here" not in result
|
|
82
|
-
|
|
83
|
-
def test_base_always_included(self):
|
|
84
|
-
parts = ["very-long-base-string-that-exceeds-width"]
|
|
85
|
-
result = fit_to_width(parts, 10)
|
|
86
|
-
assert result == "very-long-base-string-that-exceeds-width"
|
|
87
|
-
|
|
88
|
-
def test_empty_parts_skipped(self):
|
|
89
|
-
parts = ["base", "", " | ctx", "", " session"]
|
|
90
|
-
result = fit_to_width(parts, 30)
|
|
91
|
-
assert result == "base | ctx session"
|
|
92
|
-
|
|
93
|
-
def test_exact_boundary(self):
|
|
94
|
-
parts = ["12345", "67890"]
|
|
95
|
-
result = fit_to_width(parts, 10)
|
|
96
|
-
assert result == "1234567890"
|
|
97
|
-
|
|
98
|
-
def test_one_char_over_boundary(self):
|
|
99
|
-
parts = ["12345", "678901"]
|
|
100
|
-
result = fit_to_width(parts, 10)
|
|
101
|
-
assert result == "12345"
|
|
102
|
-
|
|
103
|
-
def test_empty_parts_list(self):
|
|
104
|
-
assert fit_to_width([], 80) == ""
|
|
105
|
-
|
|
106
|
-
def test_realistic_ansi_strings(self):
|
|
107
|
-
base = "\033[2m[Claude]\033[0m \033[0;34mdir\033[0m"
|
|
108
|
-
git = " | \033[0;35mmain\033[0m"
|
|
109
|
-
ctx = " | \033[0;32m150.0k (75.0%)\033[0m"
|
|
110
|
-
session = " \033[2mtest-session-uuid-1234\033[0m"
|
|
111
|
-
|
|
112
|
-
# base=[Claude] dir = 12, git= | main = 7, ctx= | 150.0k (75.0%) = 17,
|
|
113
|
-
# session= test-session-uuid-1234 = 23 => total = 59
|
|
114
|
-
result = fit_to_width([base, git, ctx, session], 80)
|
|
115
|
-
assert visible_width(result) == 59
|
|
116
|
-
|
|
117
|
-
# With tight width, session should be dropped
|
|
118
|
-
result = fit_to_width([base, git, ctx, session], 50)
|
|
119
|
-
assert "test-session-uuid-1234" not in result
|
|
120
|
-
assert visible_width(result) <= 50
|
|
121
|
-
|
|
122
|
-
def test_priority_order_preserved(self):
|
|
123
|
-
parts = ["base", " A", " B", " C", " D"]
|
|
124
|
-
# base=4, A=2, B=2, C=2, D=2 => total 12
|
|
125
|
-
# max_width=8 => base + A + B fits (8), C dropped, D dropped
|
|
126
|
-
result = fit_to_width(parts, 8)
|
|
127
|
-
assert result == "base A B"
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
"""Tests for state file rotation and session ID validation."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from claude_statusline.core.state import StateFile, _validate_session_id
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# ---------------------------------------------------------------------------
|
|
13
|
-
# Helpers
|
|
14
|
-
# ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _make_csv_line(index: int) -> str:
|
|
18
|
-
"""Generate a deterministic CSV state line for a given index."""
|
|
19
|
-
return (
|
|
20
|
-
f"{1710288000 + index},100,200,300,400,500,600,0.01,"
|
|
21
|
-
f"10,5,sess-{index},model,/tmp/proj,200000"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
# State File Rotation
|
|
27
|
-
# ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class TestStateFileRotation:
|
|
31
|
-
"""Tests for _maybe_rotate() in StateFile."""
|
|
32
|
-
|
|
33
|
-
def test_below_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
34
|
-
"""File with fewer than 10,000 lines is not rotated."""
|
|
35
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
36
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
37
|
-
(tmp_path / "old").mkdir()
|
|
38
|
-
|
|
39
|
-
sf = StateFile("test-session")
|
|
40
|
-
# Write 9,999 lines
|
|
41
|
-
lines = [_make_csv_line(i) + "\n" for i in range(9_999)]
|
|
42
|
-
sf.file_path.write_text("".join(lines))
|
|
43
|
-
|
|
44
|
-
sf._maybe_rotate()
|
|
45
|
-
|
|
46
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
47
|
-
assert len(result_lines) == 9_999
|
|
48
|
-
|
|
49
|
-
def test_at_threshold_no_rotation(self, tmp_path, monkeypatch):
|
|
50
|
-
"""File with exactly 10,000 lines is not rotated."""
|
|
51
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
52
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
53
|
-
(tmp_path / "old").mkdir()
|
|
54
|
-
|
|
55
|
-
sf = StateFile("test-session")
|
|
56
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
57
|
-
sf.file_path.write_text("".join(lines))
|
|
58
|
-
|
|
59
|
-
sf._maybe_rotate()
|
|
60
|
-
|
|
61
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
62
|
-
assert len(result_lines) == 10_000
|
|
63
|
-
|
|
64
|
-
def test_exceeds_threshold_truncates_to_5000(self, tmp_path, monkeypatch):
|
|
65
|
-
"""File with 10,001 lines is truncated to 5,000."""
|
|
66
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
67
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
68
|
-
(tmp_path / "old").mkdir()
|
|
69
|
-
|
|
70
|
-
sf = StateFile("test-session")
|
|
71
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
72
|
-
sf.file_path.write_text("".join(lines))
|
|
73
|
-
|
|
74
|
-
sf._maybe_rotate()
|
|
75
|
-
|
|
76
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
77
|
-
assert len(result_lines) == 5_000
|
|
78
|
-
|
|
79
|
-
def test_retained_lines_are_most_recent(self, tmp_path, monkeypatch):
|
|
80
|
-
"""After rotation, the retained lines are the last 5,000 of the original."""
|
|
81
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
82
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
83
|
-
(tmp_path / "old").mkdir()
|
|
84
|
-
|
|
85
|
-
sf = StateFile("test-session")
|
|
86
|
-
total = 10_001
|
|
87
|
-
lines = [_make_csv_line(i) + "\n" for i in range(total)]
|
|
88
|
-
sf.file_path.write_text("".join(lines))
|
|
89
|
-
|
|
90
|
-
sf._maybe_rotate()
|
|
91
|
-
|
|
92
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
93
|
-
# First retained line should be the one at index (total - 5000) = 5001
|
|
94
|
-
assert f"sess-{total - 5000}" in result_lines[0]
|
|
95
|
-
# Last retained line should be the last original line
|
|
96
|
-
assert f"sess-{total - 1}" in result_lines[-1]
|
|
97
|
-
|
|
98
|
-
def test_rotation_via_append_entry(self, tmp_path, monkeypatch):
|
|
99
|
-
"""append_entry triggers rotation when threshold is exceeded."""
|
|
100
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
101
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
102
|
-
(tmp_path / "old").mkdir()
|
|
103
|
-
|
|
104
|
-
sf = StateFile("test-session")
|
|
105
|
-
# Write exactly 10,000 lines (at threshold, no rotation yet)
|
|
106
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
|
|
107
|
-
sf.file_path.write_text("".join(lines))
|
|
108
|
-
|
|
109
|
-
# Import StateEntry to create a valid entry
|
|
110
|
-
from claude_statusline.core.state import StateEntry
|
|
111
|
-
|
|
112
|
-
entry = StateEntry(
|
|
113
|
-
timestamp=1710298000,
|
|
114
|
-
total_input_tokens=100,
|
|
115
|
-
total_output_tokens=200,
|
|
116
|
-
current_input_tokens=300,
|
|
117
|
-
current_output_tokens=400,
|
|
118
|
-
cache_creation=500,
|
|
119
|
-
cache_read=600,
|
|
120
|
-
cost_usd=0.01,
|
|
121
|
-
lines_added=10,
|
|
122
|
-
lines_removed=5,
|
|
123
|
-
session_id="test-session",
|
|
124
|
-
model_id="model",
|
|
125
|
-
workspace_project_dir="/tmp/proj",
|
|
126
|
-
context_window_size=200000,
|
|
127
|
-
)
|
|
128
|
-
sf.append_entry(entry)
|
|
129
|
-
|
|
130
|
-
# Now file had 10,001 lines -> should have been rotated to 5,000
|
|
131
|
-
result_lines = sf.file_path.read_text().splitlines()
|
|
132
|
-
assert len(result_lines) == 5_000
|
|
133
|
-
|
|
134
|
-
def test_no_temp_files_left_after_rotation(self, tmp_path, monkeypatch):
|
|
135
|
-
"""No .tmp files remain after successful rotation."""
|
|
136
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
137
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
138
|
-
(tmp_path / "old").mkdir()
|
|
139
|
-
|
|
140
|
-
sf = StateFile("test-session")
|
|
141
|
-
lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
|
|
142
|
-
sf.file_path.write_text("".join(lines))
|
|
143
|
-
|
|
144
|
-
sf._maybe_rotate()
|
|
145
|
-
|
|
146
|
-
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
147
|
-
assert tmp_files == []
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
# ---------------------------------------------------------------------------
|
|
151
|
-
# Session ID Validation
|
|
152
|
-
# ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestSessionIdValidation:
|
|
156
|
-
"""Tests for _validate_session_id and StateFile constructor validation."""
|
|
157
|
-
|
|
158
|
-
def test_reject_forward_slash(self):
|
|
159
|
-
with pytest.raises(ValueError, match="/"):
|
|
160
|
-
_validate_session_id("../../etc/passwd")
|
|
161
|
-
|
|
162
|
-
def test_reject_backslash(self):
|
|
163
|
-
with pytest.raises(ValueError, match=re.escape("\\")):
|
|
164
|
-
_validate_session_id("..\\..\\etc\\passwd")
|
|
165
|
-
|
|
166
|
-
def test_reject_dot_dot(self):
|
|
167
|
-
with pytest.raises(ValueError, match=r"\.\."):
|
|
168
|
-
_validate_session_id("..hidden")
|
|
169
|
-
|
|
170
|
-
def test_reject_null_byte(self):
|
|
171
|
-
with pytest.raises(ValueError):
|
|
172
|
-
_validate_session_id("session\0id")
|
|
173
|
-
|
|
174
|
-
def test_accept_valid_uuid(self):
|
|
175
|
-
_validate_session_id("abc-123-def-456") # Should not raise
|
|
176
|
-
|
|
177
|
-
def test_accept_hyphens_underscores(self):
|
|
178
|
-
_validate_session_id("my_session-id_123") # Should not raise
|
|
179
|
-
|
|
180
|
-
def test_accept_alphanumeric(self):
|
|
181
|
-
_validate_session_id("abcdef1234567890") # Should not raise
|
|
182
|
-
|
|
183
|
-
def test_statefile_rejects_invalid_session(self, tmp_path, monkeypatch):
|
|
184
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
185
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
186
|
-
(tmp_path / "old").mkdir()
|
|
187
|
-
|
|
188
|
-
with pytest.raises(ValueError):
|
|
189
|
-
StateFile("../../etc/passwd")
|
|
190
|
-
|
|
191
|
-
def test_statefile_accepts_none_session(self, tmp_path, monkeypatch):
|
|
192
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
193
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
194
|
-
(tmp_path / "old").mkdir()
|
|
195
|
-
|
|
196
|
-
sf = StateFile(None) # Should not raise
|
|
197
|
-
assert sf.session_id is None
|
|
198
|
-
|
|
199
|
-
def test_statefile_accepts_valid_session(self, tmp_path, monkeypatch):
|
|
200
|
-
monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
|
|
201
|
-
monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
|
|
202
|
-
(tmp_path / "old").mkdir()
|
|
203
|
-
|
|
204
|
-
sf = StateFile("valid-session-123") # Should not raise
|
|
205
|
-
assert sf.session_id == "valid-session-123"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# ---------------------------------------------------------------------------
|
|
209
|
-
# CLI Session ID Rejection (subprocess test)
|
|
210
|
-
# ---------------------------------------------------------------------------
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
class TestCliSessionIdRejection:
|
|
214
|
-
"""Test that context-stats CLI rejects invalid session IDs."""
|
|
215
|
-
|
|
216
|
-
def test_cli_rejects_path_traversal(self):
|
|
217
|
-
result = subprocess.run(
|
|
218
|
-
[sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd"],
|
|
219
|
-
capture_output=True,
|
|
220
|
-
text=True,
|
|
221
|
-
)
|
|
222
|
-
assert result.returncode != 0
|
|
223
|
-
assert "Invalid session_id" in result.stderr
|
|
224
|
-
|
|
225
|
-
def test_cli_rejects_backslash(self):
|
|
226
|
-
result = subprocess.run(
|
|
227
|
-
[sys.executable, "-m", "claude_statusline.cli.context_stats", "test\\bad"],
|
|
228
|
-
capture_output=True,
|
|
229
|
-
text=True,
|
|
230
|
-
)
|
|
231
|
-
assert result.returncode != 0
|
|
232
|
-
assert "Invalid session_id" in result.stderr
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
"""Tests for statusline.py script."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
SCRIPT_PATH = Path(__file__).parent.parent.parent / "scripts" / "statusline.py"
|
|
13
|
-
|
|
14
|
-
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def strip_ansi(s: str) -> str:
|
|
18
|
-
"""Strip ANSI escape sequences from a string."""
|
|
19
|
-
return _ANSI_RE.sub("", s)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def run_script(input_data: dict, env_overrides: dict | None = None) -> tuple[str, int]:
|
|
23
|
-
"""Run the statusline.py script with the given input.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
input_data: JSON-serializable dict to pass as stdin.
|
|
27
|
-
env_overrides: Optional dict of environment variable overrides.
|
|
28
|
-
|
|
29
|
-
Returns:
|
|
30
|
-
Tuple of (stdout, return_code)
|
|
31
|
-
"""
|
|
32
|
-
env = os.environ.copy()
|
|
33
|
-
env["PYTHONUTF8"] = "1"
|
|
34
|
-
if env_overrides:
|
|
35
|
-
env.update(env_overrides)
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
38
|
-
input=json.dumps(input_data),
|
|
39
|
-
capture_output=True,
|
|
40
|
-
text=True,
|
|
41
|
-
env=env,
|
|
42
|
-
)
|
|
43
|
-
return result.stdout.strip(), result.returncode
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class TestStatuslineScript:
|
|
47
|
-
"""Tests for the statusline.py script execution."""
|
|
48
|
-
|
|
49
|
-
def test_script_exists(self):
|
|
50
|
-
"""Script file should exist."""
|
|
51
|
-
assert SCRIPT_PATH.exists()
|
|
52
|
-
|
|
53
|
-
def test_script_is_python(self):
|
|
54
|
-
"""Script should have Python shebang."""
|
|
55
|
-
content = SCRIPT_PATH.read_text(encoding="utf-8")
|
|
56
|
-
assert content.startswith("#!/usr/bin/env python3")
|
|
57
|
-
|
|
58
|
-
def test_outputs_model_name(self, sample_input):
|
|
59
|
-
"""Should output the model name."""
|
|
60
|
-
output, code = run_script(sample_input)
|
|
61
|
-
assert code == 0
|
|
62
|
-
assert "Claude 3.5 Sonnet" in output
|
|
63
|
-
|
|
64
|
-
def test_outputs_directory_name(self, sample_input):
|
|
65
|
-
"""Should output the directory name."""
|
|
66
|
-
output, code = run_script(sample_input)
|
|
67
|
-
assert code == 0
|
|
68
|
-
assert "myproject" in output
|
|
69
|
-
|
|
70
|
-
def test_shows_free_tokens(self, sample_input):
|
|
71
|
-
"""Should show free tokens indicator."""
|
|
72
|
-
output, code = run_script(sample_input)
|
|
73
|
-
assert code == 0
|
|
74
|
-
assert "%" in output
|
|
75
|
-
|
|
76
|
-
def test_shows_ac_indicator(self, sample_input):
|
|
77
|
-
"""Should show autocompact indicator."""
|
|
78
|
-
output, code = run_script(sample_input)
|
|
79
|
-
assert code == 0
|
|
80
|
-
assert "[AC:" in output
|
|
81
|
-
|
|
82
|
-
def test_handles_missing_model(self):
|
|
83
|
-
"""Should handle missing model gracefully."""
|
|
84
|
-
input_data = {"workspace": {"current_dir": "/tmp/test", "project_dir": "/tmp/test"}}
|
|
85
|
-
output, code = run_script(input_data)
|
|
86
|
-
assert code == 0
|
|
87
|
-
assert "Claude" in output # Default fallback
|
|
88
|
-
|
|
89
|
-
def test_handles_invalid_json(self):
|
|
90
|
-
"""Should handle invalid JSON gracefully."""
|
|
91
|
-
env = os.environ.copy()
|
|
92
|
-
env["PYTHONUTF8"] = "1"
|
|
93
|
-
result = subprocess.run(
|
|
94
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
95
|
-
input="invalid json",
|
|
96
|
-
capture_output=True,
|
|
97
|
-
text=True,
|
|
98
|
-
env=env,
|
|
99
|
-
)
|
|
100
|
-
assert result.returncode == 0
|
|
101
|
-
assert "Claude" in result.stdout
|
|
102
|
-
|
|
103
|
-
def test_handles_empty_input(self):
|
|
104
|
-
"""Should handle empty input gracefully."""
|
|
105
|
-
env = os.environ.copy()
|
|
106
|
-
env["PYTHONUTF8"] = "1"
|
|
107
|
-
result = subprocess.run(
|
|
108
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
109
|
-
input="",
|
|
110
|
-
capture_output=True,
|
|
111
|
-
text=True,
|
|
112
|
-
env=env,
|
|
113
|
-
)
|
|
114
|
-
assert result.returncode == 0
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class TestContextWindowColors:
|
|
118
|
-
"""Tests for context window color coding."""
|
|
119
|
-
|
|
120
|
-
def test_low_usage_has_output(self, low_usage_input):
|
|
121
|
-
"""Low usage (>50% free) should produce output with 'free'."""
|
|
122
|
-
output, code = run_script(low_usage_input)
|
|
123
|
-
assert code == 0
|
|
124
|
-
assert "%" in output
|
|
125
|
-
|
|
126
|
-
def test_medium_usage_has_output(self, medium_usage_input):
|
|
127
|
-
"""Medium usage (25-50% free) should produce output with 'free'."""
|
|
128
|
-
output, code = run_script(medium_usage_input)
|
|
129
|
-
assert code == 0
|
|
130
|
-
assert "%" in output
|
|
131
|
-
|
|
132
|
-
def test_high_usage_has_output(self, high_usage_input):
|
|
133
|
-
"""High usage (<25% free) should produce output with 'free'."""
|
|
134
|
-
output, code = run_script(high_usage_input)
|
|
135
|
-
assert code == 0
|
|
136
|
-
assert "%" in output
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
class TestFixtures:
|
|
140
|
-
"""Tests using fixture files."""
|
|
141
|
-
|
|
142
|
-
def test_valid_full_fixture(self, valid_full_input):
|
|
143
|
-
"""Should handle valid_full.json fixture."""
|
|
144
|
-
output, code = run_script(valid_full_input)
|
|
145
|
-
assert code == 0
|
|
146
|
-
assert "Opus 4.5" in output
|
|
147
|
-
assert "my-project" in output
|
|
148
|
-
|
|
149
|
-
def test_valid_minimal_fixture(self, valid_minimal_input):
|
|
150
|
-
"""Should handle valid_minimal.json fixture."""
|
|
151
|
-
output, code = run_script(valid_minimal_input)
|
|
152
|
-
assert code == 0
|
|
153
|
-
assert "Claude" in output
|
|
154
|
-
|
|
155
|
-
def test_all_fixtures_succeed(self, fixtures_dir):
|
|
156
|
-
"""All JSON fixtures should be processed without errors."""
|
|
157
|
-
for fixture_file in fixtures_dir.glob("*.json"):
|
|
158
|
-
with open(fixture_file) as f:
|
|
159
|
-
input_data = json.load(f)
|
|
160
|
-
output, code = run_script(input_data)
|
|
161
|
-
assert code == 0, f"Failed on fixture: {fixture_file.name}"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
class TestSessionDisplay:
|
|
165
|
-
"""Tests for session_id display feature."""
|
|
166
|
-
|
|
167
|
-
def test_shows_session_id_by_default(self, sample_input):
|
|
168
|
-
"""Should show session_id by default (show_session=true)."""
|
|
169
|
-
sample_input["session_id"] = "test-session-12345"
|
|
170
|
-
output, code = run_script(sample_input, {"COLUMNS": "200"})
|
|
171
|
-
assert code == 0
|
|
172
|
-
assert "test-session-12345" in output
|
|
173
|
-
|
|
174
|
-
def test_handles_missing_session_id(self, sample_input):
|
|
175
|
-
"""Should handle missing session_id gracefully."""
|
|
176
|
-
# Ensure no session_id in input
|
|
177
|
-
if "session_id" in sample_input:
|
|
178
|
-
del sample_input["session_id"]
|
|
179
|
-
output, code = run_script(sample_input)
|
|
180
|
-
assert code == 0
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
class TestWidthTruncation:
|
|
184
|
-
"""Tests for width truncation to fit terminal width."""
|
|
185
|
-
|
|
186
|
-
def test_output_fits_80_columns(self, sample_input):
|
|
187
|
-
"""Output should fit within 80 columns."""
|
|
188
|
-
sample_input["session_id"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
189
|
-
output, code = run_script(sample_input, {"COLUMNS": "80"})
|
|
190
|
-
assert code == 0
|
|
191
|
-
visible = strip_ansi(output)
|
|
192
|
-
assert len(visible) <= 80
|
|
193
|
-
|
|
194
|
-
def test_output_fits_narrow_terminal(self, sample_input):
|
|
195
|
-
"""Output should fit within 40 columns and still show model+dir."""
|
|
196
|
-
output, code = run_script(sample_input, {"COLUMNS": "40"})
|
|
197
|
-
assert code == 0
|
|
198
|
-
visible = strip_ansi(output)
|
|
199
|
-
assert len(visible) <= 40
|
|
200
|
-
assert "Claude 3.5 Sonnet" in visible
|
|
201
|
-
assert "myproject" in visible
|
|
202
|
-
|
|
203
|
-
def test_wide_terminal_shows_all(self, sample_input):
|
|
204
|
-
"""Wide terminal should show session_id."""
|
|
205
|
-
sample_input["session_id"] = "test-wide-session-uuid"
|
|
206
|
-
output, code = run_script(sample_input, {"COLUMNS": "200"})
|
|
207
|
-
assert code == 0
|
|
208
|
-
assert "test-wide-session-uuid" in output
|
|
209
|
-
|
|
210
|
-
def test_full_input_truncated(self, valid_full_input):
|
|
211
|
-
"""Full input with all features should fit within 80 columns."""
|
|
212
|
-
output, code = run_script(valid_full_input, {"COLUMNS": "80"})
|
|
213
|
-
assert code == 0
|
|
214
|
-
visible = strip_ansi(output)
|
|
215
|
-
assert len(visible) <= 80
|