cc-context-stats 1.5.1 → 1.6.0
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/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
- package/.github/workflows/ci.yml +39 -2
- package/CHANGELOG.md +16 -8
- package/CLAUDE.md +54 -0
- package/CODE_OF_CONDUCT.md +59 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/RELEASE_NOTES.md +16 -7
- package/SECURITY.md +44 -0
- package/TODOS.md +72 -0
- package/docs/ARCHITECTURE.md +101 -0
- package/docs/CSV_FORMAT.md +40 -0
- package/docs/DEPLOYMENT.md +60 -0
- package/docs/DEVELOPMENT.md +125 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +1 -1
- package/scripts/statusline.js +61 -8
- package/scripts/statusline.py +9 -8
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +20 -1
- package/src/claude_statusline/core/config.py +5 -4
- package/src/claude_statusline/core/state.py +64 -7
- package/tests/bash/test_parity.bats +315 -0
- package/tests/fixtures/json/comma_in_path.json +31 -0
- package/tests/node/rotation.test.js +89 -0
- package/tests/python/test_data_pipeline.py +446 -0
- package/tests/python/test_state_rotation_validation.py +232 -0
- package/.claude/commands/context-stats.md +0 -17
- package/.claude/settings.local.json +0 -120
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Cross-implementation parity tests: Python vs Node.js statusline scripts
|
|
4
|
+
# Ensures both implementations produce equivalent output for identical input.
|
|
5
|
+
|
|
6
|
+
strip_ansi() {
|
|
7
|
+
printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
setup() {
|
|
11
|
+
PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
|
12
|
+
PYTHON_SCRIPT="$PROJECT_ROOT/scripts/statusline.py"
|
|
13
|
+
NODE_SCRIPT="$PROJECT_ROOT/scripts/statusline.js"
|
|
14
|
+
FIXTURES="$PROJECT_ROOT/tests/fixtures/json"
|
|
15
|
+
|
|
16
|
+
# Create isolated temp HOME so state files don't pollute real ~/.claude/
|
|
17
|
+
TEST_HOME=$(mktemp -d)
|
|
18
|
+
export HOME="$TEST_HOME"
|
|
19
|
+
|
|
20
|
+
# Normalize terminal width for deterministic output
|
|
21
|
+
export COLUMNS=120
|
|
22
|
+
|
|
23
|
+
# Disable delta display to avoid cross-fixture state file interference
|
|
24
|
+
mkdir -p "$TEST_HOME/.claude"
|
|
25
|
+
echo "show_delta=false" > "$TEST_HOME/.claude/statusline.conf"
|
|
26
|
+
|
|
27
|
+
# Create a non-git temp working directory so both scripts skip git info
|
|
28
|
+
TEST_WORKDIR=$(mktemp -d)
|
|
29
|
+
cd "$TEST_WORKDIR"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
teardown() {
|
|
33
|
+
rm -rf "$TEST_HOME"
|
|
34
|
+
rm -rf "$TEST_WORKDIR"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Helper: inject a session_id into a JSON fixture via Python
|
|
38
|
+
inject_session_py() {
|
|
39
|
+
local fixture="$1" session="$2"
|
|
40
|
+
python3 -c "
|
|
41
|
+
import json, sys
|
|
42
|
+
data = json.load(open('$fixture'))
|
|
43
|
+
data['session_id'] = '$session'
|
|
44
|
+
json.dump(data, sys.stdout)
|
|
45
|
+
"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Helper: inject a session_id into a JSON fixture via Node
|
|
49
|
+
inject_session_node() {
|
|
50
|
+
local fixture="$1" session="$2"
|
|
51
|
+
node -e "
|
|
52
|
+
const fs = require('fs');
|
|
53
|
+
const data = JSON.parse(fs.readFileSync('$fixture', 'utf8'));
|
|
54
|
+
data.session_id = '$session';
|
|
55
|
+
process.stdout.write(JSON.stringify(data));
|
|
56
|
+
"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# ============================================
|
|
60
|
+
# Stdout Parity Tests
|
|
61
|
+
# ============================================
|
|
62
|
+
|
|
63
|
+
@test "stdout parity: Python and Node.js produce identical ANSI-stripped output for all fixtures" {
|
|
64
|
+
for fixture in "$FIXTURES"/*.json; do
|
|
65
|
+
fixture_name=$(basename "$fixture")
|
|
66
|
+
|
|
67
|
+
py_output=$(cat "$fixture" | python3 "$PYTHON_SCRIPT" 2>/dev/null)
|
|
68
|
+
node_output=$(cat "$fixture" | node "$NODE_SCRIPT" 2>/dev/null)
|
|
69
|
+
|
|
70
|
+
py_clean=$(strip_ansi "$py_output")
|
|
71
|
+
node_clean=$(strip_ansi "$node_output")
|
|
72
|
+
|
|
73
|
+
if [ "$py_clean" != "$node_clean" ]; then
|
|
74
|
+
echo "STDOUT MISMATCH for fixture: $fixture_name"
|
|
75
|
+
echo "---"
|
|
76
|
+
echo "Python output: $py_clean"
|
|
77
|
+
echo "Node.js output: $node_clean"
|
|
78
|
+
echo "---"
|
|
79
|
+
return 1
|
|
80
|
+
fi
|
|
81
|
+
done
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# ============================================
|
|
85
|
+
# CSV State File Parity Tests
|
|
86
|
+
# ============================================
|
|
87
|
+
|
|
88
|
+
@test "CSV parity: both scripts write exactly 14 fields for all fixtures" {
|
|
89
|
+
for fixture in "$FIXTURES"/*.json; do
|
|
90
|
+
fixture_name=$(basename "$fixture" .json)
|
|
91
|
+
|
|
92
|
+
py_session="parity-py-${fixture_name}"
|
|
93
|
+
node_session="parity-node-${fixture_name}"
|
|
94
|
+
|
|
95
|
+
py_input=$(inject_session_py "$fixture" "$py_session")
|
|
96
|
+
node_input=$(inject_session_node "$fixture" "$node_session")
|
|
97
|
+
|
|
98
|
+
echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
|
|
99
|
+
echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1
|
|
100
|
+
|
|
101
|
+
py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state"
|
|
102
|
+
node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state"
|
|
103
|
+
|
|
104
|
+
# Skip fixtures that don't produce state files (e.g., no context_window data)
|
|
105
|
+
if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then
|
|
106
|
+
continue
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# If only one script wrote a state file, that's a parity failure
|
|
110
|
+
if [ ! -f "$py_state_file" ]; then
|
|
111
|
+
echo "PARITY ERROR for fixture: $fixture_name"
|
|
112
|
+
echo "Node.js wrote a state file but Python did not"
|
|
113
|
+
return 1
|
|
114
|
+
fi
|
|
115
|
+
if [ ! -f "$node_state_file" ]; then
|
|
116
|
+
echo "PARITY ERROR for fixture: $fixture_name"
|
|
117
|
+
echo "Python wrote a state file but Node.js did not"
|
|
118
|
+
return 1
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# Read last line of each state file
|
|
122
|
+
py_line=$(tail -1 "$py_state_file")
|
|
123
|
+
node_line=$(tail -1 "$node_state_file")
|
|
124
|
+
|
|
125
|
+
# Count fields (comma-separated)
|
|
126
|
+
py_field_count=$(echo "$py_line" | awk -F',' '{print NF}')
|
|
127
|
+
node_field_count=$(echo "$node_line" | awk -F',' '{print NF}')
|
|
128
|
+
|
|
129
|
+
if [ "$py_field_count" -ne 14 ]; then
|
|
130
|
+
echo "FIELD COUNT ERROR for fixture: $fixture_name"
|
|
131
|
+
echo "Python state has $py_field_count fields (expected 14)"
|
|
132
|
+
echo "Python line: $py_line"
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
135
|
+
if [ "$node_field_count" -ne 14 ]; then
|
|
136
|
+
echo "FIELD COUNT ERROR for fixture: $fixture_name"
|
|
137
|
+
echo "Node.js state has $node_field_count fields (expected 14)"
|
|
138
|
+
echo "Node.js line: $node_line"
|
|
139
|
+
return 1
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@test "CSV parity: fields 1-13 match between Python and Node.js for all fixtures" {
|
|
145
|
+
# Field names for diagnostic output (index 0 = timestamp, 1-13 = data fields)
|
|
146
|
+
local field_names=(
|
|
147
|
+
"timestamp"
|
|
148
|
+
"total_input_tokens"
|
|
149
|
+
"total_output_tokens"
|
|
150
|
+
"current_usage_input_tokens"
|
|
151
|
+
"current_usage_output_tokens"
|
|
152
|
+
"current_usage_cache_creation"
|
|
153
|
+
"current_usage_cache_read"
|
|
154
|
+
"total_cost_usd"
|
|
155
|
+
"total_lines_added"
|
|
156
|
+
"total_lines_removed"
|
|
157
|
+
"session_id"
|
|
158
|
+
"model_id"
|
|
159
|
+
"workspace_project_dir"
|
|
160
|
+
"context_window_size"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
for fixture in "$FIXTURES"/*.json; do
|
|
164
|
+
fixture_name=$(basename "$fixture" .json)
|
|
165
|
+
|
|
166
|
+
py_session="parity-fields-py-${fixture_name}"
|
|
167
|
+
node_session="parity-fields-node-${fixture_name}"
|
|
168
|
+
|
|
169
|
+
py_input=$(inject_session_py "$fixture" "$py_session")
|
|
170
|
+
node_input=$(inject_session_node "$fixture" "$node_session")
|
|
171
|
+
|
|
172
|
+
echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
|
|
173
|
+
echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1
|
|
174
|
+
|
|
175
|
+
py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state"
|
|
176
|
+
node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state"
|
|
177
|
+
|
|
178
|
+
# Skip fixtures that don't produce state files
|
|
179
|
+
if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then
|
|
180
|
+
continue
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
[ -f "$py_state_file" ] || { echo "Python wrote no state file but Node did for $fixture_name"; return 1; }
|
|
184
|
+
[ -f "$node_state_file" ] || { echo "Node wrote no state file but Python did for $fixture_name"; return 1; }
|
|
185
|
+
|
|
186
|
+
py_line=$(tail -1 "$py_state_file")
|
|
187
|
+
node_line=$(tail -1 "$node_state_file")
|
|
188
|
+
|
|
189
|
+
# Compare fields 1-13 (skip timestamp at index 0, and skip session_id at index 10 since we set different ones)
|
|
190
|
+
local has_mismatch=0
|
|
191
|
+
for i in $(seq 1 13); do
|
|
192
|
+
# Skip field 10 (session_id) since we intentionally set different session IDs
|
|
193
|
+
if [ "$i" -eq 10 ]; then
|
|
194
|
+
continue
|
|
195
|
+
fi
|
|
196
|
+
|
|
197
|
+
py_field=$(echo "$py_line" | cut -d',' -f$((i + 1)))
|
|
198
|
+
node_field=$(echo "$node_line" | cut -d',' -f$((i + 1)))
|
|
199
|
+
|
|
200
|
+
if [ "$py_field" != "$node_field" ]; then
|
|
201
|
+
echo "FIELD MISMATCH for fixture: $fixture_name"
|
|
202
|
+
echo " Field $i (${field_names[$i]}): Python='$py_field' Node='$node_field'"
|
|
203
|
+
has_mismatch=1
|
|
204
|
+
fi
|
|
205
|
+
done
|
|
206
|
+
|
|
207
|
+
if [ "$has_mismatch" -eq 1 ]; then
|
|
208
|
+
echo "Full Python line: $py_line"
|
|
209
|
+
echo "Full Node.js line: $node_line"
|
|
210
|
+
return 1
|
|
211
|
+
fi
|
|
212
|
+
done
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@test "CSV parity: comma in workspace_project_dir is sanitized to underscore" {
|
|
216
|
+
fixture="$FIXTURES/comma_in_path.json"
|
|
217
|
+
[ -f "$fixture" ] || skip "comma_in_path.json fixture missing"
|
|
218
|
+
|
|
219
|
+
# Enable show_delta so state files are written
|
|
220
|
+
echo "show_delta=true" > "$TEST_HOME/.claude/statusline.conf"
|
|
221
|
+
|
|
222
|
+
py_session="parity-comma-py"
|
|
223
|
+
node_session="parity-comma-node"
|
|
224
|
+
|
|
225
|
+
py_input=$(inject_session_py "$fixture" "$py_session")
|
|
226
|
+
node_input=$(inject_session_node "$fixture" "$node_session")
|
|
227
|
+
|
|
228
|
+
echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
|
|
229
|
+
echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1
|
|
230
|
+
|
|
231
|
+
# Restore show_delta=false for subsequent tests
|
|
232
|
+
echo "show_delta=false" > "$TEST_HOME/.claude/statusline.conf"
|
|
233
|
+
|
|
234
|
+
py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state"
|
|
235
|
+
node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state"
|
|
236
|
+
|
|
237
|
+
[ -f "$py_state_file" ] || { echo "Python wrote no state file"; return 1; }
|
|
238
|
+
[ -f "$node_state_file" ] || { echo "Node wrote no state file"; return 1; }
|
|
239
|
+
|
|
240
|
+
# Extract workspace_project_dir (field 13, 1-indexed in cut)
|
|
241
|
+
py_dir=$(tail -1 "$py_state_file" | cut -d',' -f13)
|
|
242
|
+
node_dir=$(tail -1 "$node_state_file" | cut -d',' -f13)
|
|
243
|
+
|
|
244
|
+
# Both must have commas replaced with underscores
|
|
245
|
+
expected="/home/user/my_project_dir"
|
|
246
|
+
|
|
247
|
+
if [ "$py_dir" != "$expected" ]; then
|
|
248
|
+
echo "Python did not sanitize commas: got '$py_dir', expected '$expected'"
|
|
249
|
+
return 1
|
|
250
|
+
fi
|
|
251
|
+
if [ "$node_dir" != "$expected" ]; then
|
|
252
|
+
echo "Node.js did not sanitize commas: got '$node_dir', expected '$expected'"
|
|
253
|
+
return 1
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# Both must produce exactly 14 fields (commas didn't corrupt the CSV)
|
|
257
|
+
py_fields=$(tail -1 "$py_state_file" | awk -F',' '{print NF}')
|
|
258
|
+
node_fields=$(tail -1 "$node_state_file" | awk -F',' '{print NF}')
|
|
259
|
+
|
|
260
|
+
if [ "$py_fields" -ne 14 ]; then
|
|
261
|
+
echo "Python state has $py_fields fields (expected 14)"
|
|
262
|
+
return 1
|
|
263
|
+
fi
|
|
264
|
+
if [ "$node_fields" -ne 14 ]; then
|
|
265
|
+
echo "Node.js state has $node_fields fields (expected 14)"
|
|
266
|
+
return 1
|
|
267
|
+
fi
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@test "CSV parity: timestamp differs by at most 2 seconds between Python and Node.js" {
|
|
271
|
+
for fixture in "$FIXTURES"/*.json; do
|
|
272
|
+
fixture_name=$(basename "$fixture" .json)
|
|
273
|
+
|
|
274
|
+
py_session="parity-ts-py-${fixture_name}"
|
|
275
|
+
node_session="parity-ts-node-${fixture_name}"
|
|
276
|
+
|
|
277
|
+
py_input=$(inject_session_py "$fixture" "$py_session")
|
|
278
|
+
node_input=$(inject_session_node "$fixture" "$node_session")
|
|
279
|
+
|
|
280
|
+
echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1
|
|
281
|
+
echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1
|
|
282
|
+
|
|
283
|
+
py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state"
|
|
284
|
+
node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state"
|
|
285
|
+
|
|
286
|
+
# Skip fixtures that don't produce state files
|
|
287
|
+
if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then
|
|
288
|
+
continue
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
[ -f "$py_state_file" ] || { echo "Python wrote no state file but Node did for $fixture_name"; return 1; }
|
|
292
|
+
[ -f "$node_state_file" ] || { echo "Node wrote no state file but Python did for $fixture_name"; return 1; }
|
|
293
|
+
|
|
294
|
+
py_line=$(tail -1 "$py_state_file")
|
|
295
|
+
node_line=$(tail -1 "$node_state_file")
|
|
296
|
+
|
|
297
|
+
# Extract timestamp (field 0, which is field 1 in cut 1-indexed)
|
|
298
|
+
py_ts=$(echo "$py_line" | cut -d',' -f1)
|
|
299
|
+
node_ts=$(echo "$node_line" | cut -d',' -f1)
|
|
300
|
+
|
|
301
|
+
# Calculate absolute difference
|
|
302
|
+
if [ -n "$py_ts" ] && [ -n "$node_ts" ]; then
|
|
303
|
+
diff=$((py_ts - node_ts))
|
|
304
|
+
abs_diff=${diff#-}
|
|
305
|
+
|
|
306
|
+
if [ "$abs_diff" -gt 2 ]; then
|
|
307
|
+
echo "TIMESTAMP DRIFT for fixture: $fixture_name"
|
|
308
|
+
echo " Python timestamp: $py_ts"
|
|
309
|
+
echo " Node.js timestamp: $node_ts"
|
|
310
|
+
echo " Difference: ${abs_diff}s (max allowed: 2s)"
|
|
311
|
+
return 1
|
|
312
|
+
fi
|
|
313
|
+
fi
|
|
314
|
+
done
|
|
315
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": {
|
|
3
|
+
"display_name": "Opus 4.5",
|
|
4
|
+
"api_name": "claude-opus-4-5",
|
|
5
|
+
"id": "claude-opus-4-5"
|
|
6
|
+
},
|
|
7
|
+
"workspace": {
|
|
8
|
+
"current_dir": "/home/user/my,project,dir",
|
|
9
|
+
"project_dir": "/home/user/my,project,dir"
|
|
10
|
+
},
|
|
11
|
+
"context_window": {
|
|
12
|
+
"context_window_size": 200000,
|
|
13
|
+
"total_input_tokens": 75000,
|
|
14
|
+
"total_output_tokens": 8500,
|
|
15
|
+
"current_usage": {
|
|
16
|
+
"input_tokens": 50000,
|
|
17
|
+
"output_tokens": 5000,
|
|
18
|
+
"cache_creation_input_tokens": 10000,
|
|
19
|
+
"cache_read_input_tokens": 20000
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"cost": {
|
|
23
|
+
"total_cost_usd": 0.05234,
|
|
24
|
+
"total_duration_ms": 120000,
|
|
25
|
+
"total_api_duration_ms": 5000,
|
|
26
|
+
"total_lines_added": 250,
|
|
27
|
+
"total_lines_removed": 45
|
|
28
|
+
},
|
|
29
|
+
"session_id": "test-comma-path",
|
|
30
|
+
"version": "1.0.80"
|
|
31
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
// Import rotation function from statusline.js
|
|
6
|
+
// The script reads stdin on require, so we mock stdin to prevent hanging
|
|
7
|
+
const originalStdin = process.stdin;
|
|
8
|
+
|
|
9
|
+
// Prevent the script's stdin listener from blocking
|
|
10
|
+
jest.spyOn(process.stdin, 'setEncoding').mockImplementation(() => {});
|
|
11
|
+
jest.spyOn(process.stdin, 'on').mockImplementation(() => {});
|
|
12
|
+
|
|
13
|
+
const { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP } = require('../../scripts/statusline.js');
|
|
14
|
+
|
|
15
|
+
function makeCsvLine(index) {
|
|
16
|
+
return `${1710288000 + index},100,200,300,400,500,600,0.01,10,5,sess-${index},model,/tmp/proj,200000`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('maybeRotateStateFile', () => {
|
|
20
|
+
let tmpDir;
|
|
21
|
+
let stateFile;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rotation-test-'));
|
|
25
|
+
stateFile = path.join(tmpDir, 'test.state');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('file below threshold is not rotated', () => {
|
|
33
|
+
const lines = Array.from({ length: 9999 }, (_, i) => makeCsvLine(i));
|
|
34
|
+
fs.writeFileSync(stateFile, lines.join('\n') + '\n');
|
|
35
|
+
|
|
36
|
+
maybeRotateStateFile(stateFile);
|
|
37
|
+
|
|
38
|
+
const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n');
|
|
39
|
+
expect(result.length).toBe(9999);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('file at exactly threshold is not rotated', () => {
|
|
43
|
+
const lines = Array.from({ length: ROTATION_THRESHOLD }, (_, i) => makeCsvLine(i));
|
|
44
|
+
fs.writeFileSync(stateFile, lines.join('\n') + '\n');
|
|
45
|
+
|
|
46
|
+
maybeRotateStateFile(stateFile);
|
|
47
|
+
|
|
48
|
+
const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n');
|
|
49
|
+
expect(result.length).toBe(ROTATION_THRESHOLD);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('file exceeding threshold is truncated to ROTATION_KEEP lines', () => {
|
|
53
|
+
const lines = Array.from({ length: 10001 }, (_, i) => makeCsvLine(i));
|
|
54
|
+
fs.writeFileSync(stateFile, lines.join('\n') + '\n');
|
|
55
|
+
|
|
56
|
+
maybeRotateStateFile(stateFile);
|
|
57
|
+
|
|
58
|
+
const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n');
|
|
59
|
+
expect(result.length).toBe(ROTATION_KEEP);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('retained lines are the most recent', () => {
|
|
63
|
+
const total = 10001;
|
|
64
|
+
const lines = Array.from({ length: total }, (_, i) => makeCsvLine(i));
|
|
65
|
+
fs.writeFileSync(stateFile, lines.join('\n') + '\n');
|
|
66
|
+
|
|
67
|
+
maybeRotateStateFile(stateFile);
|
|
68
|
+
|
|
69
|
+
const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n');
|
|
70
|
+
// First retained line should be index (total - ROTATION_KEEP)
|
|
71
|
+
expect(result[0]).toContain(`sess-${total - ROTATION_KEEP}`);
|
|
72
|
+
// Last retained line should be the last original line
|
|
73
|
+
expect(result[result.length - 1]).toContain(`sess-${total - 1}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('non-existent file does not throw', () => {
|
|
77
|
+
expect(() => maybeRotateStateFile('/tmp/nonexistent-rotation-test.state')).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('no temp files remain after rotation', () => {
|
|
81
|
+
const lines = Array.from({ length: 10001 }, (_, i) => makeCsvLine(i));
|
|
82
|
+
fs.writeFileSync(stateFile, lines.join('\n') + '\n');
|
|
83
|
+
|
|
84
|
+
maybeRotateStateFile(stateFile);
|
|
85
|
+
|
|
86
|
+
const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tmp'));
|
|
87
|
+
expect(tmpFiles.length).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
});
|