Files
everything-claude-code/tests/hooks/suggest-compact.test.js
Gaurav Dubey a08445ad78 fix(suggest-compact): clean up old counter temp files (#2159)
* fix(suggest-compact): clean up old counter temp files

claude-tool-count-<sessionId> files were written into the OS temp dir
on every hook run and never removed, accumulating one orphan per
session indefinitely.

Sweep stale counter files at the top of main() before opening the
active counter. Retention is env-tunable via COMPACT_STATE_TTL_DAYS
(default 14 days); invalid values fall back to the default. The
active session's counter file is preserved unconditionally even if
its mtime is past the cutoff. Failures during the sweep are swallowed
to preserve the always-exit-0 hook contract.

Adds 7 regression tests covering the sweep, env-var validation, and
the always-exit-0 invariant under a populated temp dir.

Fixes #2156

* fix(suggest-compact): preserve counter files at the TTL cutoff boundary

The cleanup sweep used `mtimeMs > cutoffMs` to short-circuit, which
matched files whose mtime sits exactly on the cutoff boundary and
deleted them. The cleanupOldCounters docstring promises only files
*older than* retentionDays are removed; a file at age == retentionDays
is not older than retentionDays, so it must survive.

Switch the comparison to `>=` so only strictly older files fall
through to deletion. Add a regression test that pins boundary-aged
files (mtimeMs sitting just past the projected cutoff) are preserved.

Refs #2156
2026-06-07 13:01:27 +08:00

647 lines
25 KiB
JavaScript

/**
* Tests for scripts/hooks/suggest-compact.js
*
* Tests the tool-call counter, threshold logic, interval suggestions,
* and environment variable handling.
*
* Run with: node tests/hooks/suggest-compact.test.js
*/
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawnSync } = require('child_process');
const compactScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'suggest-compact.js');
// Test helpers
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (_err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${_err.message}`);
return false;
}
}
/**
* Run suggest-compact.js with optional env overrides.
* Returns { code, stdout, stderr }.
*/
function runCompact(envOverrides = {}) {
const env = { ...process.env, ...envOverrides };
const result = spawnSync('node', [compactScript], {
encoding: 'utf8',
input: '{}',
timeout: 10000,
env,
});
return {
code: result.status || 0,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
/**
* Get the counter file path for a given session ID.
*/
function getCounterFilePath(sessionId) {
return path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
}
let counterContextSeq = 0;
function createCounterContext(prefix = 'test-compact') {
counterContextSeq += 1;
const sessionId = `${prefix}-${Date.now()}-${counterContextSeq}`;
const counterFile = getCounterFilePath(sessionId);
return {
sessionId,
counterFile,
cleanup() {
try {
fs.unlinkSync(counterFile);
} catch (_err) {
// Ignore missing temp files between runs
}
}
};
}
function runTests() {
console.log('\n=== Testing suggest-compact.js ===\n');
let passed = 0;
let failed = 0;
// Basic functionality
console.log('Basic counter functionality:');
if (test('creates counter file on first run', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(fs.existsSync(counterFile), 'Counter file should be created');
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Counter should be 1 after first run');
cleanup();
})) passed++;
else failed++;
if (test('increments counter on subsequent runs', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
runCompact({ CLAUDE_SESSION_ID: sessionId });
runCompact({ CLAUDE_SESSION_ID: sessionId });
runCompact({ CLAUDE_SESSION_ID: sessionId });
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 3, 'Counter should be 3 after three runs');
cleanup();
})) passed++;
else failed++;
// Threshold suggestion
console.log('\nThreshold suggestion:');
if (test('suggests compact at threshold (COMPACT_THRESHOLD=3)', () => {
const { sessionId, cleanup } = createCounterContext();
cleanup();
// Run 3 times with threshold=3
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
assert.ok(
result.stderr.includes('3 tool calls reached') || result.stderr.includes('consider /compact'),
`Should suggest compact at threshold. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('does NOT suggest compact before threshold', () => {
const { sessionId, cleanup } = createCounterContext();
cleanup();
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
assert.ok(
!result.stderr.includes('StrategicCompact'),
'Should NOT suggest compact before threshold'
);
cleanup();
})) passed++;
else failed++;
// Interval suggestion (every 25 calls after threshold)
console.log('\nInterval suggestion:');
if (test('suggests at threshold + 25 interval', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
// Set counter to threshold+24 (so next run = threshold+25)
// threshold=3, so we need count=28 → 25 calls past threshold
// Write 27 to the counter file, next run will be 28 = 3 + 25
fs.writeFileSync(counterFile, '27');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
// count=28, threshold=3, 28-3=25, 25 % 25 === 0 → should suggest
assert.ok(
result.stderr.includes('28 tool calls') || result.stderr.includes('checkpoint'),
`Should suggest at threshold+25 interval. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
// Environment variable handling
console.log('\nEnvironment variable handling:');
if (test('uses default threshold (50) when COMPACT_THRESHOLD is not set', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
// Write counter to 49, next run will be 50 = default threshold
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
// Remove COMPACT_THRESHOLD from env
assert.ok(
result.stderr.includes('50 tool calls reached'),
`Should use default threshold of 50. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('ignores invalid COMPACT_THRESHOLD (negative)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '-5' });
// Invalid threshold falls back to 50
assert.ok(
result.stderr.includes('50 tool calls reached'),
`Should fallback to 50 for negative threshold. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('ignores non-numeric COMPACT_THRESHOLD', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: 'abc' });
// NaN falls back to 50
assert.ok(
result.stderr.includes('50 tool calls reached'),
`Should fallback to 50 for non-numeric threshold. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
// Corrupted counter file
console.log('\nCorrupted counter file:');
if (test('resets counter on corrupted file content', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, 'not-a-number');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0);
// Corrupted file → parsed is NaN → falls back to count=1
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Should reset to 1 on corrupted file');
cleanup();
})) passed++;
else failed++;
if (test('resets counter on extremely large value', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
// Value > 1000000 should be clamped
fs.writeFileSync(counterFile, '9999999');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0);
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Should reset to 1 for value > 1000000');
cleanup();
})) passed++;
else failed++;
if (test('handles empty counter file', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0);
// Empty file → bytesRead=0 → count starts at 1
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Should start at 1 for empty file');
cleanup();
})) passed++;
else failed++;
// Session isolation
console.log('\nSession isolation:');
if (test('uses separate counter files per session ID', () => {
const sessionA = `compact-a-${Date.now()}`;
const sessionB = `compact-b-${Date.now()}`;
const fileA = getCounterFilePath(sessionA);
const fileB = getCounterFilePath(sessionB);
try {
runCompact({ CLAUDE_SESSION_ID: sessionA });
runCompact({ CLAUDE_SESSION_ID: sessionA });
runCompact({ CLAUDE_SESSION_ID: sessionB });
const countA = parseInt(fs.readFileSync(fileA, 'utf8').trim(), 10);
const countB = parseInt(fs.readFileSync(fileB, 'utf8').trim(), 10);
assert.strictEqual(countA, 2, 'Session A should have count 2');
assert.strictEqual(countB, 1, 'Session B should have count 1');
} finally {
try { fs.unlinkSync(fileA); } catch (_err) { /* ignore */ }
try { fs.unlinkSync(fileB); } catch (_err) { /* ignore */ }
}
})) passed++;
else failed++;
// Always exits 0
console.log('\nExit code:');
if (test('always exits 0 (never blocks Claude)', () => {
const { sessionId, cleanup } = createCounterContext();
cleanup();
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should always exit 0');
cleanup();
})) passed++;
else failed++;
// ── Round 29: threshold boundary values ──
console.log('\nThreshold boundary values:');
if (test('rejects COMPACT_THRESHOLD=0 (falls back to 50)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '0' });
// 0 is invalid (must be > 0), falls back to 50, count becomes 50 → should suggest
assert.ok(
result.stderr.includes('50 tool calls reached'),
`Should fallback to 50 for threshold=0. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('accepts COMPACT_THRESHOLD=10000 (boundary max)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '9999');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10000' });
// count becomes 10000, threshold=10000 → should suggest
assert.ok(
result.stderr.includes('10000 tool calls reached'),
`Should accept threshold=10000. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('rejects COMPACT_THRESHOLD=10001 (falls back to 50)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '10001' });
// 10001 > 10000, invalid, falls back to 50, count becomes 50 → should suggest
assert.ok(
result.stderr.includes('50 tool calls reached'),
`Should fallback to 50 for threshold=10001. Got stderr: ${result.stderr}`
);
cleanup();
})) passed++;
else failed++;
if (test('rejects float COMPACT_THRESHOLD (e.g. 3.5)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3.5' });
// parseInt('3.5') = 3, which is valid (> 0 && <= 10000)
// count becomes 50, threshold=3, 50-3=47, 47%25≠0 and 50≠3 → no suggestion
assert.strictEqual(result.code, 0);
// No suggestion expected (50 !== 3, and (50-3) % 25 !== 0)
assert.ok(
!result.stderr.includes('StrategicCompact'),
'Float threshold should be parseInt-ed to 3, no suggestion at count=50'
);
cleanup();
})) passed++;
else failed++;
if (test('counter value at exact boundary 1000000 is valid', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '999999');
runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
// 999999 is valid (> 0, <= 1000000), count becomes 1000000
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1000000, 'Counter at 1000000 boundary should be valid');
cleanup();
})) passed++;
else failed++;
if (test('counter value at 1000001 is clamped (reset to 1)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '1000001');
runCompact({ CLAUDE_SESSION_ID: sessionId });
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Counter > 1000000 should be reset to 1');
cleanup();
})) passed++;
else failed++;
// ── hookSpecificOutput JSON on stdout ──
// Claude Code 2.1+ drops non-blocking PreToolUse stderr; the suggestion has
// to ride on stdout as { hookSpecificOutput: { additionalContext } } to reach
// the model. These tests pin that contract.
console.log('\nhookSpecificOutput stdout JSON:');
if (test('emits hookSpecificOutput.additionalContext on stdout at threshold', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at threshold. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse',
`hookEventName should be PreToolUse. Got: ${JSON.stringify(parsed)}`);
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('50 tool calls reached'),
`additionalContext should include threshold text. Got: ${parsed.hookSpecificOutput.additionalContext}`);
cleanup();
})) passed++;
else failed++;
if (test('emits hookSpecificOutput.additionalContext on stdout at +25 interval', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
// threshold=3, set counter to 27 → next run = 28 → 28-3=25 → interval hit
fs.writeFileSync(counterFile, '27');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '3' });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload at interval. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('28 tool calls'),
`additionalContext should include count. Got: ${parsed.hookSpecificOutput.additionalContext}`);
cleanup();
})) passed++;
else failed++;
if (test('emits no stdout below threshold (silent)', () => {
const { sessionId, cleanup } = createCounterContext();
cleanup();
const result = runCompact({ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '5' });
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout.trim(), '',
`Expected empty stdout below threshold. Got: "${result.stdout}"`);
cleanup();
})) passed++;
else failed++;
if (test('still writes [StrategicCompact] to stderr (debug log retained)', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '49');
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.ok(result.stderr.includes('[StrategicCompact]'),
`stderr should retain [StrategicCompact] for debug log capture. Got: "${result.stderr}"`);
cleanup();
})) passed++;
else failed++;
// ── Round 64: default session ID fallback ──
console.log('\nDefault session ID fallback (Round 64):');
if (test('uses "default" session ID when CLAUDE_SESSION_ID is empty', () => {
const defaultCounterFile = getCounterFilePath('default');
try { fs.unlinkSync(defaultCounterFile); } catch (_err) { /* ignore */ }
try {
// Pass empty CLAUDE_SESSION_ID — falsy, so script uses 'default'
const env = { ...process.env, CLAUDE_SESSION_ID: '' };
const result = spawnSync('node', [compactScript], {
encoding: 'utf8',
input: '{}',
timeout: 10000,
env,
});
assert.strictEqual(result.status || 0, 0, 'Should exit 0');
assert.ok(fs.existsSync(defaultCounterFile), 'Counter file should use "default" session ID');
const count = parseInt(fs.readFileSync(defaultCounterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, 'Counter should be 1 for first run with default session');
} finally {
try { fs.unlinkSync(defaultCounterFile); } catch (_err) { /* ignore */ }
}
})) passed++;
else failed++;
// ── Counter file cleanup (#2156) ──
// claude-tool-count-<sessionId> files were never removed. The hook now
// sweeps stale counters older than COMPACT_STATE_TTL_DAYS (default 14)
// before opening the active counter. These tests pin the contract.
console.log('\nCounter file cleanup (#2156):');
/**
* Set a file's mtime/atime to N days ago.
*/
function setMtimeDaysAgo(filePath, daysAgo) {
const seconds = Math.floor(Date.now() / 1000) - daysAgo * 24 * 60 * 60;
fs.utimesSync(filePath, seconds, seconds);
}
if (test('removes counter files older than retention window', () => {
const { sessionId, cleanup } = createCounterContext();
const stale = getCounterFilePath(`stale-${Date.now()}`);
fs.writeFileSync(stale, '1');
setMtimeDaysAgo(stale, 30);
try {
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(!fs.existsSync(stale),
`Stale counter file should have been swept. Path: ${stale}`);
} finally {
try { fs.unlinkSync(stale); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('preserves counter files within retention window', () => {
const { sessionId, cleanup } = createCounterContext();
const fresh = getCounterFilePath(`fresh-${Date.now()}`);
fs.writeFileSync(fresh, '1');
setMtimeDaysAgo(fresh, 5);
try {
runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.ok(fs.existsSync(fresh),
`Fresh counter file should be preserved. Path: ${fresh}`);
} finally {
try { fs.unlinkSync(fresh); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('preserves the active session\'s counter file even if old', () => {
const { sessionId, counterFile, cleanup } = createCounterContext();
cleanup();
fs.writeFileSync(counterFile, '7');
setMtimeDaysAgo(counterFile, 30);
try {
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Should exit 0');
// Active counter survives the sweep AND is incremented by the hook (7 -> 8).
assert.ok(fs.existsSync(counterFile),
'Active session counter must survive the sweep');
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 8,
`Active counter should be incremented by the hook. Got ${count}`);
} finally {
cleanup();
}
})) passed++;
else failed++;
if (test('honours COMPACT_STATE_TTL_DAYS env var', () => {
const { sessionId, cleanup } = createCounterContext();
const target = getCounterFilePath(`ttl-${Date.now()}`);
fs.writeFileSync(target, '1');
setMtimeDaysAgo(target, 5); // Within default 14d window, but outside TTL=3
try {
const result = runCompact({
CLAUDE_SESSION_ID: sessionId,
COMPACT_STATE_TTL_DAYS: '3'
});
assert.strictEqual(result.code, 0);
assert.ok(!fs.existsSync(target),
`TTL=3 should sweep a 5-day-old file. Path: ${target}`);
} finally {
try { fs.unlinkSync(target); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('falls back to default for invalid COMPACT_STATE_TTL_DAYS', () => {
const { sessionId, cleanup } = createCounterContext();
const target = getCounterFilePath(`fallback-${Date.now()}`);
fs.writeFileSync(target, '1');
setMtimeDaysAgo(target, 5); // Within default 14d window, would survive a fallback
try {
// Each invalid form: zero, negative, non-numeric — should fall back to 14d default.
for (const bad of ['0', '-5', 'abc']) {
// Reset mtime each iteration so the file remains 5 days old.
setMtimeDaysAgo(target, 5);
const result = runCompact({
CLAUDE_SESSION_ID: sessionId,
COMPACT_STATE_TTL_DAYS: bad
});
assert.strictEqual(result.code, 0);
assert.ok(fs.existsSync(target),
`Invalid TTL '${bad}' should fall back to default (14d) and preserve a 5-day-old file`);
}
} finally {
try { fs.unlinkSync(target); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('does not touch unrelated temp files', () => {
const { sessionId, cleanup } = createCounterContext();
const unrelated = path.join(os.tmpdir(), `unrelated-${Date.now()}.tmp`);
fs.writeFileSync(unrelated, 'do not touch');
setMtimeDaysAgo(unrelated, 60);
try {
runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.ok(fs.existsSync(unrelated),
`Unrelated temp file should not be swept. Path: ${unrelated}`);
} finally {
try { fs.unlinkSync(unrelated); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('preserves files whose mtime sits at or after the TTL cutoff', () => {
// Contract: docstring says files "older than" retentionDays are removed.
// A file at the exact boundary (age == retentionDays) is NOT older than
// retentionDays, so it must survive the sweep. Pins the >= comparison
// in cleanupOldCounters: anything with mtimeMs >= cutoffMs is skipped.
//
// We can't pin the boundary by clock — the sweep computes its own
// Date.now() after this test runs, so `setMtimeDaysAgo(file, 14)` is
// effectively "14d + handful of ms", placing the file just past the
// cutoff. To exercise the boundary deterministically, set the file's
// mtime two seconds *newer* than the projected cutoff: with `>` the
// file would be deleted (mtimeMs > cutoffMs is false at the cutoff
// edge); with `>=` it survives.
const { sessionId, cleanup } = createCounterContext();
const boundary = getCounterFilePath(`boundary-${Date.now()}`);
fs.writeFileSync(boundary, '1');
const retentionDays = 14;
const boundaryMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + 2000;
const sec = Math.floor(boundaryMs / 1000);
fs.utimesSync(boundary, sec, sec);
try {
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0);
assert.ok(fs.existsSync(boundary),
`Boundary-aged counter file should be preserved. Path: ${boundary}`);
} finally {
try { fs.unlinkSync(boundary); } catch (_err) { /* ignore */ }
cleanup();
}
})) passed++;
else failed++;
if (test('exit 0 holds when sweep encounters a populated temp dir', () => {
// Functional smoke: with a mix of stale, fresh, and unrelated files
// present, the hook must still exit 0 — the always-exit-0 contract
// takes precedence over sweep failures.
const { sessionId, cleanup } = createCounterContext();
const stale = getCounterFilePath(`mix-stale-${Date.now()}`);
const fresh = getCounterFilePath(`mix-fresh-${Date.now()}`);
const unrelated = path.join(os.tmpdir(), `mix-unrelated-${Date.now()}.tmp`);
fs.writeFileSync(stale, '1');
fs.writeFileSync(fresh, '1');
fs.writeFileSync(unrelated, '1');
setMtimeDaysAgo(stale, 30);
setMtimeDaysAgo(fresh, 1);
setMtimeDaysAgo(unrelated, 30);
try {
const result = runCompact({ CLAUDE_SESSION_ID: sessionId });
assert.strictEqual(result.code, 0, 'Hook must exit 0 even with files in temp dir');
} finally {
for (const p of [stale, fresh, unrelated]) {
try { fs.unlinkSync(p); } catch (_err) { /* ignore */ }
}
cleanup();
}
})) passed++;
else failed++;
// Summary
console.log(`
Results: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();