Skip to content

Commit f4259dd

Browse files
committed
test: add stale-path regression tests and harden jq null safety
Add tests for the exact scenario PR #21 fixes: - 7e: stale Nix/NixOS path replaced with current path, no duplicates - 7f: unrelated hooks in the same entry survive stale-path cleanup - 7g: entries with null/missing hooks field don't crash the jq filter Harden the jq cleanup filter to null-coalesce .hooks before mapping, matching the defensive style already used in the detection path.
1 parent 6f7011a commit f4259dd

2 files changed

Lines changed: 133 additions & 2 deletions

File tree

test/run-tests.sh

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,6 +1909,137 @@ else
19091909
fail "User settings lost during unconfigure"
19101910
fi
19111911
1912+
# --- Test 7e: Stale-path replacement (Nix/NixOS regression) ---
1913+
#
1914+
# On Nix/NixOS each rebuild produces a new /nix/store hash. The old
1915+
# contains()-based check would see "yes, a claude-session-track hook
1916+
# exists" and skip reinstall, leaving a stale (garbage-collected) path.
1917+
# The fix compares on exact path equality and replaces stale entries.
1918+
1919+
echo ""
1920+
echo "=== Test 7e: Stale-path replacement (Nix/NixOS regression) ==="
1921+
echo ""
1922+
1923+
# Start fresh
1924+
rm -f "$HOME/.claude/settings.json"
1925+
echo '{}' >"$HOME/.claude/settings.json"
1926+
1927+
# Inject hooks pointing at a fake old path (simulates a previous Nix derivation)
1928+
stale_track="bash '/nix/store/old-hash-abc123/hooks/claude-session-track.sh'"
1929+
stale_cleanup="bash '/nix/store/old-hash-abc123/hooks/claude-session-cleanup.sh'"
1930+
tmp_stale=$(mktemp)
1931+
jq --arg track "$stale_track" --arg cleanup "$stale_cleanup" '
1932+
.hooks = {
1933+
"SessionStart": [{
1934+
"matcher": "",
1935+
"hooks": [{"type": "command", "command": $track}]
1936+
}],
1937+
"SessionEnd": [{
1938+
"matcher": "",
1939+
"hooks": [{"type": "command", "command": $cleanup}]
1940+
}]
1941+
}
1942+
' "$HOME/.claude/settings.json" >"$tmp_stale" && mv "$tmp_stale" "$HOME/.claude/settings.json"
1943+
1944+
# Verify stale hooks are in place
1945+
stale_before=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
1946+
assert_eq "Stale: old-path hook present before reinstall" "1" "$stale_before"
1947+
1948+
# Run the plugin — should replace the stale path with the current one
1949+
bash "$REPO_DIR/tmux-assistant-resurrect.tmux"
1950+
1951+
# Exactly 1 SessionStart hook, not 2 (no duplicate)
1952+
stale_start_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
1953+
assert_eq "Stale: exactly 1 SessionStart hook after reinstall" "1" "$stale_start_count"
1954+
1955+
# The hook must point at the CURRENT path, not the old one
1956+
stale_start_cmd=$(jq -r '.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track")) | .command' "$HOME/.claude/settings.json")
1957+
expected_track_cmd="bash '${REPO_DIR}/hooks/claude-session-track.sh'"
1958+
assert_eq "Stale: SessionStart hook updated to current path" "$expected_track_cmd" "$stale_start_cmd"
1959+
1960+
# Same for SessionEnd
1961+
stale_end_count=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup"))] | length' "$HOME/.claude/settings.json")
1962+
assert_eq "Stale: exactly 1 SessionEnd hook after reinstall" "1" "$stale_end_count"
1963+
1964+
stale_end_cmd=$(jq -r '.hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup")) | .command' "$HOME/.claude/settings.json")
1965+
expected_cleanup_cmd="bash '${REPO_DIR}/hooks/claude-session-cleanup.sh'"
1966+
assert_eq "Stale: SessionEnd hook updated to current path" "$expected_cleanup_cmd" "$stale_end_cmd"
1967+
1968+
# Run again — should be idempotent (still exactly 1)
1969+
bash "$REPO_DIR/tmux-assistant-resurrect.tmux"
1970+
stale_idem_count=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
1971+
assert_eq "Stale: idempotent after path replacement" "1" "$stale_idem_count"
1972+
1973+
# --- Test 7f: Stale path with other hooks preserved ---
1974+
#
1975+
# When a stale hook sits alongside an unrelated hook in the same entry,
1976+
# only the stale hook should be removed; the unrelated one must survive.
1977+
1978+
echo ""
1979+
echo "=== Test 7f: Stale path replacement preserves unrelated hooks ==="
1980+
echo ""
1981+
1982+
rm -f "$HOME/.claude/settings.json"
1983+
echo '{}' >"$HOME/.claude/settings.json"
1984+
1985+
# Inject a SessionStart entry with BOTH a stale track hook and a user's custom hook
1986+
tmp_mixed=$(mktemp)
1987+
jq --arg stale "$stale_track" '
1988+
.hooks = {
1989+
"SessionStart": [{
1990+
"matcher": "",
1991+
"hooks": [
1992+
{"type": "command", "command": $stale},
1993+
{"type": "command", "command": "echo my-custom-hook"}
1994+
]
1995+
}]
1996+
}
1997+
' "$HOME/.claude/settings.json" >"$tmp_mixed" && mv "$tmp_mixed" "$HOME/.claude/settings.json"
1998+
1999+
bash "$REPO_DIR/tmux-assistant-resurrect.tmux"
2000+
2001+
# The custom hook must survive
2002+
mixed_custom=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command == "echo my-custom-hook")] | length' "$HOME/.claude/settings.json")
2003+
assert_eq "Mixed: unrelated hook preserved after stale replacement" "1" "$mixed_custom"
2004+
2005+
# Our hook is present with the current path
2006+
mixed_track=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
2007+
assert_eq "Mixed: exactly 1 track hook after stale replacement" "1" "$mixed_track"
2008+
2009+
# --- Test 7g: Stale path with null/missing hooks field ---
2010+
#
2011+
# An entry with "hooks": null or no hooks field at all must not crash
2012+
# the jq cleanup filter (.hooks |= map(...) would fail without null-coalescing).
2013+
2014+
echo ""
2015+
echo "=== Test 7g: Stale path cleanup tolerates null hooks field ==="
2016+
echo ""
2017+
2018+
rm -f "$HOME/.claude/settings.json"
2019+
cat >"$HOME/.claude/settings.json" <<'NULLEOF'
2020+
{
2021+
"hooks": {
2022+
"SessionStart": [
2023+
{"matcher": "", "hooks": null},
2024+
{"matcher": "", "hooks": [{"type": "command", "command": "bash '/nix/store/old-hash/hooks/claude-session-track.sh'"}]}
2025+
],
2026+
"SessionEnd": [
2027+
{"matcher": ""}
2028+
]
2029+
}
2030+
}
2031+
NULLEOF
2032+
2033+
null_exit=0
2034+
bash "$REPO_DIR/tmux-assistant-resurrect.tmux" 2>&1 || null_exit=$?
2035+
assert_eq "Null hooks: install doesn't crash" "0" "$null_exit"
2036+
2037+
null_track=$(jq '[.hooks.SessionStart[]?.hooks[]? | select((.command // "") | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
2038+
assert_eq "Null hooks: exactly 1 track hook installed" "1" "$null_track"
2039+
2040+
null_end=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select((.command // "") | contains("claude-session-cleanup"))] | length' "$HOME/.claude/settings.json")
2041+
assert_eq "Null hooks: exactly 1 cleanup hook installed" "1" "$null_end"
2042+
19122043
# --- Test 8: strip_assistant_pane_contents() ---
19132044
19142045
suite "strip_pane_contents"

tmux-assistant-resurrect.tmux

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ install_claude_hooks() {
7272
.hooks.SessionStart //= [] |
7373
# Drop any prior instance of this hook (different paths included).
7474
.hooks.SessionStart |= map(
75-
.hooks |= map(select((.command // "") | contains("claude-session-track") | not))
75+
.hooks = ((.hooks // []) | map(select((.command // "") | contains("claude-session-track") | not)))
7676
) |
7777
# Drop entries whose hooks list became empty after the filter.
7878
.hooks.SessionStart |= map(select((.hooks // []) | length > 0)) |
@@ -91,7 +91,7 @@ install_claude_hooks() {
9191
.hooks //= {} |
9292
.hooks.SessionEnd //= [] |
9393
.hooks.SessionEnd |= map(
94-
.hooks |= map(select((.command // "") | contains("claude-session-cleanup") | not))
94+
.hooks = ((.hooks // []) | map(select((.command // "") | contains("claude-session-cleanup") | not)))
9595
) |
9696
.hooks.SessionEnd |= map(select((.hooks // []) | length > 0)) |
9797
.hooks.SessionEnd += [{

0 commit comments

Comments
 (0)