| 1 | """Tests for hooks/scripts/check-config.sh auto-creating LAST30DAYS_MEMORY_DIR. |
| 2 | |
| 3 | Covers issue #395 — fresh installs failed silently on first --emit=html run |
| 4 | because nothing created the default memory dir. The SessionStart hook should |
| 5 | mkdir -p the configured memory dir on every run. |
| 6 | |
| 7 | The default path is the same one used throughout the engine: |
| 8 | LAST30DAYS_MEMORY_DIR="${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}" |
| 9 | |
| 10 | Cases: |
| 11 | - LAST30DAYS_MEMORY_DIR points to a non-existent path -> dir is created |
| 12 | - LAST30DAYS_MEMORY_DIR points to an existing path -> no error, exit 0 |
| 13 | - LAST30DAYS_MEMORY_DIR unset -> default dir is created |
| 14 | - LAST30DAYS_MEMORY_DIR points to an unwritable path -> script still exits 0 |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import os |
| 20 | import shutil |
| 21 | import subprocess |
| 22 | import sys |
| 23 | from pathlib import Path |
| 24 | |
| 25 | import pytest |
| 26 | |
| 27 | HOOK = Path(__file__).resolve().parents[1] / "hooks" / "scripts" / "check-config.sh" |
| 28 | |
| 29 | |
| 30 | def _run_hook(env_overrides: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess: |
| 31 | env = os.environ.copy() |
| 32 | # Clear any pre-existing keys so the test is deterministic. |
| 33 | for k in ("LAST30DAYS_MEMORY_DIR", "SETUP_COMPLETE", "LAST30DAYS_CONFIG_DIR"): |
| 34 | env.pop(k, None) |
| 35 | env.update(env_overrides) |
| 36 | return subprocess.run( |
| 37 | ["bash", str(HOOK)], |
| 38 | capture_output=True, |
| 39 | text=True, |
| 40 | env=env, |
| 41 | cwd=str(cwd) if cwd else None, |
| 42 | timeout=30, |
| 43 | ) |
| 44 | |
| 45 | |
| 46 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 47 | def test_creates_dir_when_memory_dir_missing(tmp_path: Path): |
| 48 | target = tmp_path / "Last30Days" |
| 49 | assert not target.exists() |
| 50 | |
| 51 | result = _run_hook({"LAST30DAYS_MEMORY_DIR": str(target)}) |
| 52 | |
| 53 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 54 | assert target.is_dir(), "LAST30DAYS_MEMORY_DIR was not created" |
| 55 | |
| 56 | |
| 57 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 58 | def test_no_error_when_memory_dir_already_exists(tmp_path: Path): |
| 59 | target = tmp_path / "Last30Days" |
| 60 | target.mkdir() |
| 61 | sentinel = target / "sentinel.txt" |
| 62 | sentinel.write_text("preserve me") |
| 63 | |
| 64 | result = _run_hook({"LAST30DAYS_MEMORY_DIR": str(target)}) |
| 65 | |
| 66 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 67 | assert sentinel.read_text() == "preserve me", "existing dir contents were disturbed" |
| 68 | |
| 69 | |
| 70 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 71 | def test_default_memory_dir_created_when_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): |
| 72 | # Override $HOME so the default fallback path lands inside tmp_path. |
| 73 | fake_home = tmp_path / "home" |
| 74 | fake_home.mkdir() |
| 75 | monkeypatch.setenv("HOME", str(fake_home)) |
| 76 | expected = fake_home / "Documents" / "Last30Days" |
| 77 | assert not expected.exists() |
| 78 | |
| 79 | # Use a clean env without LAST30DAYS_MEMORY_DIR (also drop SETUP_COMPLETE so |
| 80 | # the welcome path runs, but it doesn't matter — mkdir runs first). |
| 81 | result = _run_hook({}) |
| 82 | |
| 83 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 84 | assert expected.is_dir(), f"default dir {expected} was not created" |
| 85 | |
| 86 | |
| 87 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 88 | def test_tolerates_unwritable_memory_dir(tmp_path: Path): |
| 89 | """Hook should swallow mkdir errors and still exit 0 — never crash Claude Code startup.""" |
| 90 | # /proc/1 is owned by root; under sandboxed runners this will fail with EACCES. |
| 91 | bad_path = "/proc/should/not/be/writable/last30days-test-395" |
| 92 | if os.path.exists(bad_path): |
| 93 | pytest.skip("unwritable test path already exists; skipping") |
| 94 | |
| 95 | result = _run_hook({"LAST30DAYS_MEMORY_DIR": bad_path}) |
| 96 | |
| 97 | # Either mkdir silently failed (2>/dev/null) or it succeeded under a permissive |
| 98 | # test runner. Both are acceptable. The contract is: exit 0, no crash. |
| 99 | assert result.returncode == 0, ( |
| 100 | f"hook should not crash on mkdir failure: rc={result.returncode} stderr={result.stderr!r}" |
| 101 | ) |
| 102 |