| 1 | """Tests for hooks/scripts/check-config.sh yt-dlp detection on the new-user path. |
| 2 | |
| 3 | Covers issue #394 — new users with yt-dlp installed were never told YouTube was |
| 4 | available, because the capability-detection block ran AFTER the new-user early |
| 5 | exit. The SessionStart hook should detect yt-dlp on PATH and mention it in the |
| 6 | welcome message even when no config exists. |
| 7 | |
| 8 | Cases: |
| 9 | - new user + yt-dlp on PATH -> welcome says YouTube works out of the box |
| 10 | - new user + no yt-dlp on PATH -> welcome unchanged (wizard can unlock YouTube) |
| 11 | - existing user + yt-dlp -> numeric source count is higher than without |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import json |
| 17 | import os |
| 18 | import re |
| 19 | import shutil |
| 20 | import subprocess |
| 21 | from pathlib import Path |
| 22 | |
| 23 | import pytest |
| 24 | |
| 25 | HOOK = Path(__file__).resolve().parents[1] / "hooks" / "scripts" / "check-config.sh" |
| 26 | |
| 27 | |
| 28 | def _run_hook(env_overrides: dict[str, str], path_override: str | None = None) -> subprocess.CompletedProcess: |
| 29 | env = os.environ.copy() |
| 30 | for k in ( |
| 31 | "LAST30DAYS_MEMORY_DIR", |
| 32 | "SETUP_COMPLETE", |
| 33 | "LAST30DAYS_CONFIG_DIR", |
| 34 | "OPENAI_API_KEY", |
| 35 | "SCRAPECREATORS_API_KEY", |
| 36 | "AUTH_TOKEN", |
| 37 | "XAI_API_KEY", |
| 38 | "CT0", |
| 39 | "BSKY_HANDLE", |
| 40 | "BSKY_APP_PASSWORD", |
| 41 | "EXA_API_KEY", |
| 42 | ): |
| 43 | env.pop(k, None) |
| 44 | env.update(env_overrides) |
| 45 | if path_override is not None: |
| 46 | env["PATH"] = path_override |
| 47 | bash_path = shutil.which("bash") |
| 48 | if bash_path is None: |
| 49 | pytest.skip("bash not on PATH") |
| 50 | return subprocess.run( |
| 51 | [bash_path, str(HOOK)], |
| 52 | capture_output=True, |
| 53 | text=True, |
| 54 | env=env, |
| 55 | timeout=30, |
| 56 | ) |
| 57 | |
| 58 | |
| 59 | def _tool_path_without_ytdlp(tmp_path: Path) -> str: |
| 60 | """Build a tiny PATH with hook dependencies but no yt-dlp.""" |
| 61 | tool_bin = tmp_path / "tool_bin" |
| 62 | tool_bin.mkdir(exist_ok=True) |
| 63 | for name in ("cat", "id", "mkdir", "python3", "sed", "stat", "tr", "uname"): |
| 64 | target = shutil.which(name) |
| 65 | if target is None: |
| 66 | if name == "python3": |
| 67 | continue |
| 68 | pytest.skip(f"{name} not on PATH") |
| 69 | link = tool_bin / name |
| 70 | if not link.exists(): |
| 71 | link.symlink_to(target) |
| 72 | assert shutil.which("yt-dlp", path=str(tool_bin)) is None |
| 73 | return str(tool_bin) |
| 74 | |
| 75 | |
| 76 | def _write_fake_last_run(tmp_path: Path) -> str: |
| 77 | """Write a minimal last-run.json and return the LAST30DAYS_CONFIG_DIR the hook |
| 78 | should be pointed at. |
| 79 | |
| 80 | The hook reads ``$LAST30DAYS_CONFIG_DIR/last-run.json`` and runs a python3 |
| 81 | subshell on it. Without this file, the hook's last-run line stays empty |
| 82 | and a pre-existing bug (#440) makes the script exit 1 even on success. |
| 83 | We don't want our regression test to depend on that bug, so we always |
| 84 | provide a well-formed last-run.json. |
| 85 | """ |
| 86 | cfg_dir = tmp_path / "last30days_cfg" |
| 87 | cfg_dir.mkdir() |
| 88 | (cfg_dir / "last-run.json").write_text( |
| 89 | json.dumps( |
| 90 | { |
| 91 | "topic": "test topic", |
| 92 | "timestamp": "2026-06-01T00:00:00Z", |
| 93 | "total": 0, |
| 94 | } |
| 95 | ) |
| 96 | ) |
| 97 | return str(cfg_dir) |
| 98 | |
| 99 | |
| 100 | def _parse_source_count(stdout: str) -> int: |
| 101 | """Extract the source count from the 'Ready — N sources active.' line. |
| 102 | |
| 103 | The script emits the count in both the fully-configured and the |
| 104 | 'setup-done but missing ScrapeCreators' branches. |
| 105 | """ |
| 106 | match = re.search(r"Ready\s+[—–-]\s+(\d+)\s+sources?\s+active", stdout) |
| 107 | assert match, f"could not find source count in hook stdout: {stdout!r}" |
| 108 | return int(match.group(1)) |
| 109 | |
| 110 | |
| 111 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 112 | def test_new_user_with_ytdlp_says_youtube_works(tmp_path: Path): |
| 113 | """A new user with yt-dlp on PATH should see YouTube flagged as already-working.""" |
| 114 | fake_bin = tmp_path / "fake_bin" |
| 115 | fake_bin.mkdir() |
| 116 | (fake_bin / "yt-dlp").touch() |
| 117 | (fake_bin / "yt-dlp").chmod(0o755) |
| 118 | |
| 119 | # PATH must contain bash (so the hook can run) AND the fake yt-dlp dir. |
| 120 | # Putting fake_bin FIRST means any real yt-dlp elsewhere is shadowed. |
| 121 | path = f"{fake_bin}:{_tool_path_without_ytdlp(tmp_path)}" |
| 122 | assert shutil.which("yt-dlp", path=path) is not None, ( |
| 123 | "test pre-condition: fake yt-dlp should resolve on the override PATH" |
| 124 | ) |
| 125 | |
| 126 | cfg_dir = _write_fake_last_run(tmp_path) |
| 127 | result = _run_hook({"LAST30DAYS_CONFIG_DIR": cfg_dir}, path_override=path) |
| 128 | |
| 129 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 130 | # The welcome message is now consistent: it explicitly says YouTube is |
| 131 | # working via yt-dlp, AND drops "YouTube" from the wizard-unlock line so |
| 132 | # the two don't contradict each other (see #394 follow-up). |
| 133 | assert "Detected: yt-dlp" in result.stdout, ( |
| 134 | f"expected yt-dlp detection line, got: {result.stdout!r}" |
| 135 | ) |
| 136 | assert "YouTube (yt-dlp detected) work out of the box" in result.stdout, ( |
| 137 | f"expected yt-dlp-aware YouTube line, got: {result.stdout!r}" |
| 138 | ) |
| 139 | # The wizard line should NOT advertise YouTube as something the wizard unlocks, |
| 140 | # because yt-dlp is already providing it. |
| 141 | assert "wizard can unlock X/Twitter, YouTube, and more" not in result.stdout, ( |
| 142 | f"welcome should not claim wizard unlocks YouTube when yt-dlp is present: {result.stdout!r}" |
| 143 | ) |
| 144 | |
| 145 | |
| 146 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 147 | def test_new_user_without_ytdlp_unchanged_welcome(tmp_path: Path): |
| 148 | """A new user without yt-dlp should see the original wizard-unlock copy.""" |
| 149 | path = _tool_path_without_ytdlp(tmp_path) |
| 150 | assert shutil.which("yt-dlp", path=path) is None, ( |
| 151 | "test pre-condition: yt-dlp should not resolve on the minimal PATH" |
| 152 | ) |
| 153 | |
| 154 | cfg_dir = _write_fake_last_run(tmp_path) |
| 155 | result = _run_hook({"LAST30DAYS_CONFIG_DIR": cfg_dir}, path_override=path) |
| 156 | |
| 157 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 158 | # No detection line, no yt-dlp-aware copy, original wizard line preserved. |
| 159 | assert "Detected: yt-dlp" not in result.stdout |
| 160 | assert "yt-dlp detected" not in result.stdout |
| 161 | assert "wizard can unlock X/Twitter, YouTube, and more" in result.stdout, ( |
| 162 | f"expected unchanged wizard line, got: {result.stdout!r}" |
| 163 | ) |
| 164 | |
| 165 | |
| 166 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 167 | def test_setup_done_user_source_count_includes_ytdlp(tmp_path: Path): |
| 168 | """Regression: the setup-done path must count YouTube when yt-dlp is on PATH. |
| 169 | |
| 170 | Runs the hook twice — once with yt-dlp and once without — and asserts the |
| 171 | numeric source count is exactly 1 higher with yt-dlp present. This catches |
| 172 | a real regression where HAS_YTDLP gets zeroed out before the counting block. |
| 173 | """ |
| 174 | cfg_dir = _write_fake_last_run(tmp_path) |
| 175 | base_env = { |
| 176 | "SETUP_COMPLETE": "true", |
| 177 | "SCRAPECREATORS_API_KEY": "sc_test", |
| 178 | "LAST30DAYS_CONFIG_DIR": cfg_dir, |
| 179 | } |
| 180 | |
| 181 | # 1) Run WITH yt-dlp |
| 182 | fake_bin = tmp_path / "fake_bin_with" |
| 183 | fake_bin.mkdir() |
| 184 | (fake_bin / "yt-dlp").touch() |
| 185 | (fake_bin / "yt-dlp").chmod(0o755) |
| 186 | path_with = f"{fake_bin}:{_tool_path_without_ytdlp(tmp_path)}" |
| 187 | assert shutil.which("yt-dlp", path=path_with) is not None |
| 188 | |
| 189 | with_yt = _run_hook(base_env, path_override=path_with) |
| 190 | assert with_yt.returncode == 0, f"hook failed: stderr={with_yt.stderr!r}" |
| 191 | count_with = _parse_source_count(with_yt.stdout) |
| 192 | |
| 193 | # 2) Run WITHOUT yt-dlp (minimal PATH) |
| 194 | path_without = _tool_path_without_ytdlp(tmp_path) |
| 195 | assert shutil.which("yt-dlp", path=path_without) is None |
| 196 | |
| 197 | without_yt = _run_hook(base_env, path_override=path_without) |
| 198 | assert without_yt.returncode == 0, f"hook failed: stderr={without_yt.stderr!r}" |
| 199 | count_without = _parse_source_count(without_yt.stdout) |
| 200 | |
| 201 | # YouTube adds exactly one source to the count. |
| 202 | assert count_with == count_without + 1, ( |
| 203 | f"expected YouTube to add exactly 1 source; got " |
| 204 | f"{count_with} (with yt-dlp) vs {count_without} (without). " |
| 205 | f"Stdout with: {with_yt.stdout!r}\n" |
| 206 | f"Stdout without: {without_yt.stdout!r}" |
| 207 | ) |
| 208 | |
| 209 | |
| 210 | @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") |
| 211 | def test_keychain_credentials_avoid_new_user_welcome(tmp_path: Path): |
| 212 | """macOS Keychain credentials should count as configured for the status hook.""" |
| 213 | fake_bin = tmp_path / "fake_bin_keychain" |
| 214 | fake_bin.mkdir() |
| 215 | |
| 216 | uname = fake_bin / "uname" |
| 217 | uname.write_text("#!/bin/sh\necho Darwin\n", encoding="utf-8") |
| 218 | uname.chmod(0o755) |
| 219 | |
| 220 | security = fake_bin / "security" |
| 221 | security.write_text( |
| 222 | """#!/bin/sh |
| 223 | service="" |
| 224 | while [ "$#" -gt 0 ]; do |
| 225 | if [ "$1" = "-s" ]; then |
| 226 | service="$2" |
| 227 | shift 2 |
| 228 | else |
| 229 | shift |
| 230 | fi |
| 231 | done |
| 232 | case "$service" in |
| 233 | last30days-XAI_API_KEY|last30days-SCRAPECREATORS_API_KEY) exit 0 ;; |
| 234 | *) exit 44 ;; |
| 235 | esac |
| 236 | """, |
| 237 | encoding="utf-8", |
| 238 | ) |
| 239 | security.chmod(0o755) |
| 240 | |
| 241 | cfg_dir = _write_fake_last_run(tmp_path) |
| 242 | path = f"{fake_bin}:{_tool_path_without_ytdlp(tmp_path)}" |
| 243 | result = _run_hook( |
| 244 | { |
| 245 | "HOME": str(tmp_path), |
| 246 | "LAST30DAYS_CONFIG_DIR": cfg_dir, |
| 247 | }, |
| 248 | path_override=path, |
| 249 | ) |
| 250 | |
| 251 | assert result.returncode == 0, f"hook failed: stderr={result.stderr!r}" |
| 252 | assert "Ready to use. Run /last30days" not in result.stdout |
| 253 | assert "Ready" in result.stdout |
| 254 | assert "sources active" in result.stdout |
| 255 | assert "Tip: Add ScrapeCreators" not in result.stdout |
| 256 |