返回 last30days-skill
test_check_config_ytdlp_detection.py
根目录 / tests / test_check_config_ytdlp_detection.py
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
256 lines PYTHON