返回 last30days-skill
test_setup_wizard.py
根目录 / tests / test_setup_wizard.py
1 """Tests for the first-run setup wizard module."""
2
3 import os
4 import subprocess
5 import tempfile
6 from pathlib import Path
7 from unittest.mock import patch, MagicMock
8
9 import pytest
10
11 from lib import setup_wizard
12
13
14 class _NtOs:
15 """Delegates to the real os module but reports name == 'nt'.
16
17 Patched only in setup_wizard's own namespace (mirrors
18 tests/test_health_probe_taxonomy.py's ``_NtOs``) so pathlib and the rest
19 of the test process are unaffected.
20 """
21 name = "nt"
22
23 def __getattr__(self, attr):
24 return getattr(os, attr)
25
26
27 class _PosixOs:
28 """Delegates to the real os module but reports name == 'posix'."""
29 name = "posix"
30
31 def __getattr__(self, attr):
32 return getattr(os, attr)
33
34
35 class TestIsFirstRun:
36 """Tests for is_first_run()."""
37
38 def test_first_run_when_setup_complete_not_set(self):
39 """SETUP_COMPLETE not in config -> first run."""
40 config = {"AUTH_TOKEN": "abc", "CT0": "xyz"}
41 assert setup_wizard.is_first_run(config) is True
42
43 def test_first_run_when_setup_complete_is_none(self):
44 """SETUP_COMPLETE=None -> first run."""
45 config = {"SETUP_COMPLETE": None}
46 assert setup_wizard.is_first_run(config) is True
47
48 def test_first_run_when_setup_complete_is_empty(self):
49 """SETUP_COMPLETE="" -> first run."""
50 config = {"SETUP_COMPLETE": ""}
51 assert setup_wizard.is_first_run(config) is True
52
53 def test_not_first_run_when_setup_complete_true(self):
54 """SETUP_COMPLETE=true -> not first run."""
55 config = {"SETUP_COMPLETE": "true"}
56 assert setup_wizard.is_first_run(config) is False
57
58 def test_not_first_run_when_setup_complete_any_value(self):
59 """SETUP_COMPLETE set to any truthy value -> not first run."""
60 config = {"SETUP_COMPLETE": "yes"}
61 assert setup_wizard.is_first_run(config) is False
62
63
64 class TestRunAutoSetup:
65 """Tests for run_auto_setup()."""
66
67 @patch("lib.cookie_extract.extract_cookies_with_source")
68 @patch("shutil.which")
69 def test_cookies_found(self, mock_which, mock_extract):
70 """When cookies are found, results dict includes them."""
71 mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
72 mock_which.return_value = "/usr/local/bin/yt-dlp"
73
74 config = {}
75 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
76
77 assert "x" in results["cookies_found"]
78 assert results["cookies_found"]["x"] == "chrome"
79 assert results["ytdlp_installed"] is True
80 assert results["ytdlp_action"] == "already_installed"
81 assert results["env_written"] is False
82 assert results["browser_cookie_scan_attempted"] is True
83
84 @patch("lib.cookie_extract.extract_cookies_with_source")
85 @patch("shutil.which")
86 def test_no_cookies_found(self, mock_which, mock_extract, monkeypatch):
87 """When no cookies found, results dict has empty cookies_found."""
88 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
89 mock_extract.return_value = None
90 mock_which.return_value = None
91
92 config = {}
93 results = setup_wizard.run_auto_setup(config)
94
95 assert results["cookies_found"] == {}
96 mock_extract.assert_not_called()
97 assert results["browser_cookie_scan_attempted"] is False
98 assert results["ytdlp_installed"] is False
99 assert results["ytdlp_action"] == "no_homebrew"
100
101 @patch("lib.cookie_extract.extract_cookies_with_source")
102 @patch("shutil.which")
103 def test_cookie_extraction_exception(self, mock_which, mock_extract):
104 """Cookie extraction raising an exception is handled gracefully."""
105 mock_extract.side_effect = Exception("DB locked")
106 mock_which.return_value = None
107
108 config = {}
109 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
110
111 assert results["cookies_found"] == {}
112 assert results["browser_cookie_scan_attempted"] is True
113
114 @patch("lib.cookie_extract.extract_cookies_with_source")
115 @patch("shutil.which")
116 def test_multiple_sources(self, mock_which, mock_extract):
117 """Multiple cookie sources can be found."""
118 def side_effect(browser, domain, cookie_names):
119 if domain == ".x.com":
120 return ({"auth_token": "abc", "ct0": "xyz"}, "firefox")
121 elif domain == ".truthsocial.com":
122 return ({"_session_id": "sess123"}, "firefox")
123 return None
124
125 mock_extract.side_effect = side_effect
126 mock_which.return_value = None
127
128 config = {}
129 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
130
131 assert results["cookies_found"]["x"] == "firefox"
132 assert results["cookies_found"]["truthsocial"] == "firefox"
133
134 @patch("lib.cookie_extract.extract_cookies_with_source")
135 @patch("shutil.which")
136 def test_default_scan_order_is_chromium_first(self, mock_which, mock_extract):
137 """U1: with FROM_BROWSER unset, the scan tries Chrome before Safari.
138
139 Safari is the only X-cookie source needing Full Disk Access; Chrome
140 reads via the Keychain with no FDA. The wizard must try the Chromium
141 family first so the common macOS user does not hit the FDA dead-end.
142 """
143 tried = []
144
145 def side_effect(browser, domain, cookie_names):
146 tried.append(browser)
147 return None # never find cookies, so every browser is attempted
148
149 mock_extract.side_effect = side_effect
150 mock_which.return_value = None
151
152 setup_wizard.run_auto_setup({}, allow_browser_cookies=True)
153
154 # Order within the first domain's attempts must be chromium-first.
155 assert "chrome" in tried and "safari" in tried
156 assert tried.index("chrome") < tried.index("safari")
157 assert tried.index("chrome") < tried.index("firefox")
158 # And it must not route through the silent-first "auto" order.
159 assert tried[0] == "chrome"
160
161
162 @patch("lib.cookie_extract.extract_cookies_with_source")
163 @patch("shutil.which")
164 def test_official_only_host_skips_cookie_loop_for_every_domain(self, mock_which, mock_extract):
165 """U6/R5: on an official-only host (LAST30DAYS_HOST=grok-bot) the
166 consented cookie scan is skipped for X AND Truth Social, the result
167 records a neutral note, and the CLI installs (yt-dlp) still run."""
168 mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
169 mock_which.return_value = "/usr/local/bin/yt-dlp"
170
171 config = {"LAST30DAYS_HOST": "grok-bot"}
172 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
173
174 mock_extract.assert_not_called()
175 assert results["cookies_found"] == {}
176 assert results["browser_cookie_scan_attempted"] is False
177 assert results["cookie_note"] == "browser sessions are not read on this host"
178 # The free CLI installs still run: the yt-dlp probe happened.
179 assert any(call.args[:1] == ("yt-dlp",) for call in mock_which.call_args_list)
180 assert results["ytdlp_installed"] is True
181 assert results["ytdlp_action"] == "already_installed"
182
183 @patch("lib.cookie_extract.extract_cookies_with_source")
184 @patch("shutil.which")
185 def test_official_only_host_status_text_names_no_cookies(self, mock_which, mock_extract):
186 """R4: the setup summary on an official-only host never names cookies."""
187 mock_which.return_value = None
188 results = setup_wizard.run_auto_setup(
189 {"LAST30DAYS_HOST": "grok-bot"}, allow_browser_cookies=True
190 )
191 results["env_written"] = True # what the CLI sets before rendering
192 text = setup_wizard.get_setup_status_text(results)
193 assert setup_wizard.OFFICIAL_HOST_COOKIE_NOTE in text
194 assert "Configuration saved." in text
195 lowered = text.lower()
196 for banned in ("cookies", "cdp", "box-chrome", "bird", "auth_token", "ct0", "xquik", "browsers"):
197 assert banned not in lowered, f"{banned!r} leaked into setup status text: {text!r}"
198
199 def test_status_text_without_note_keeps_browser_tail(self):
200 """R14: non-Grok hosts keep today's status tail byte for byte."""
201 text = setup_wizard.get_setup_status_text({"env_written": True, "ytdlp_action": "already_installed"})
202 assert "Configuration saved. Future runs will auto-detect your browsers." in text
203
204 @patch("lib.cookie_extract.extract_cookies_with_source")
205 @patch("shutil.which")
206 def test_bird_pin_on_official_only_host_re_enables_cookie_scan(self, mock_which, mock_extract):
207 """KTD7: the exclusive LAST30DAYS_X_BACKEND=bird pin is the one path
208 that re-enables discovery on an official-only host."""
209 mock_extract.return_value = None
210 mock_which.return_value = None
211 config = {"LAST30DAYS_HOST": "grok-bot", "LAST30DAYS_X_BACKEND": "bird"}
212 results = setup_wizard.run_auto_setup(config, allow_browser_cookies=True)
213 assert mock_extract.called
214 assert results["browser_cookie_scan_attempted"] is True
215 assert "cookie_note" not in results
216
217
218 class TestYtdlpAutoInstall:
219 """Tests for yt-dlp auto-install via Homebrew in run_auto_setup()."""
220
221 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
222 @patch("subprocess.run")
223 @patch("shutil.which")
224 def test_ytdlp_missing_brew_available_installs(self, mock_which, mock_subproc, mock_extract, monkeypatch):
225 """yt-dlp missing + brew available (non-Windows) -> installs via brew."""
226 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
227 def which_side_effect(cmd):
228 if cmd == "yt-dlp":
229 return None
230 if cmd == "brew":
231 return "/opt/homebrew/bin/brew"
232 return None
233 mock_which.side_effect = which_side_effect
234 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
235
236 results = setup_wizard.run_auto_setup({})
237
238 mock_subproc.assert_called_once_with(
239 ["brew", "install", "yt-dlp"],
240 capture_output=True, text=True, timeout=120,
241 )
242 assert results["ytdlp_installed"] is True
243 assert results["ytdlp_action"] == "installed"
244
245 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
246 @patch("shutil.which")
247 def test_ytdlp_missing_brew_missing(self, mock_which, mock_extract, monkeypatch):
248 """yt-dlp missing + brew missing (non-Windows) -> no_homebrew."""
249 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
250 mock_which.return_value = None
251
252 results = setup_wizard.run_auto_setup({})
253
254 assert results["ytdlp_installed"] is False
255 assert results["ytdlp_action"] == "no_homebrew"
256
257 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
258 @patch("shutil.which")
259 def test_ytdlp_missing_on_windows(self, mock_which, mock_extract, monkeypatch):
260 """Regression for #904: yt-dlp missing on Windows -> pip guidance, no
261 Homebrew attempt (Windows has no Homebrew and pip is the working path)."""
262 monkeypatch.setattr(setup_wizard, "os", _NtOs())
263 mock_which.return_value = None
264
265 with patch("subprocess.run") as mock_subproc:
266 results = setup_wizard.run_auto_setup({})
267 mock_subproc.assert_not_called()
268
269 assert results["ytdlp_installed"] is False
270 assert results["ytdlp_action"] == "no_pip_windows"
271
272 text = setup_wizard.get_setup_status_text(results)
273 assert "pip install yt-dlp" in text
274 assert "Homebrew" not in text
275 assert "Scripts" in text
276
277 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
278 @patch("shutil.which")
279 def test_ytdlp_already_installed(self, mock_which, mock_extract):
280 """yt-dlp already installed -> already_installed."""
281 mock_which.return_value = "/usr/local/bin/yt-dlp"
282
283 results = setup_wizard.run_auto_setup({})
284
285 assert results["ytdlp_installed"] is True
286 assert results["ytdlp_action"] == "already_installed"
287
288 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
289 @patch("subprocess.run")
290 @patch("shutil.which")
291 def test_brew_install_fails(self, mock_which, mock_subproc, mock_extract, monkeypatch):
292 """brew install yt-dlp fails (non-Windows) -> install_failed with stderr."""
293 monkeypatch.setattr(setup_wizard, "os", _PosixOs())
294 def which_side_effect(cmd):
295 if cmd == "yt-dlp":
296 return None
297 if cmd == "brew":
298 return "/opt/homebrew/bin/brew"
299 return None
300 mock_which.side_effect = which_side_effect
301 mock_subproc.return_value = MagicMock(returncode=1, stderr="Error: something broke")
302
303 results = setup_wizard.run_auto_setup({})
304
305 assert results["ytdlp_installed"] is False
306 assert results["ytdlp_action"] == "install_failed"
307 assert "something broke" in results["ytdlp_stderr"]
308
309
310 class TestDiggAutoInstall:
311 """Tests for digg-pp-cli auto-install via npx in run_auto_setup()."""
312
313 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
314 @patch("shutil.which")
315 def test_digg_already_installed(self, mock_which, mock_extract):
316 """digg-pp-cli already on PATH -> already_installed, no subprocess."""
317 # yt-dlp missing + brew missing keeps the yt-dlp path subprocess-free;
318 # digg-pp-cli present short-circuits before any npx call.
319 def which_side_effect(cmd):
320 return "/Users/me/go/bin/digg-pp-cli" if cmd == "digg-pp-cli" else None
321 mock_which.side_effect = which_side_effect
322
323 with patch("subprocess.run") as mock_subproc:
324 results = setup_wizard.run_auto_setup({})
325 mock_subproc.assert_not_called()
326
327 assert results["digg_installed"] is True
328 assert results["digg_action"] == "already_installed"
329
330 # Redirect HOME/GOPATH so real ~/.local/bin or ~/go/bin digg-pp-cli on the
331 # dev box does not make the binary look present during absence tests.
332 @staticmethod
333 def _empty_home(tmp_path, monkeypatch):
334 monkeypatch.delenv("GOPATH", raising=False)
335 monkeypatch.setenv("HOME", str(tmp_path))
336
337 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
338 @patch("shutil.which")
339 def test_digg_no_npx(self, mock_which, mock_extract, tmp_path, monkeypatch):
340 """digg-pp-cli missing + npx missing -> no_npx, no subprocess."""
341 self._empty_home(tmp_path, monkeypatch)
342 mock_which.return_value = None
343
344 with patch("subprocess.run") as mock_subproc:
345 results = setup_wizard.run_auto_setup({})
346 mock_subproc.assert_not_called()
347
348 assert results["digg_installed"] is False
349 assert results["digg_action"] == "no_npx"
350
351 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
352 @patch("subprocess.run")
353 @patch("shutil.which")
354 def test_digg_install_succeeds(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
355 """npx present + install succeeds + binary verifiable -> installed."""
356 self._empty_home(tmp_path, monkeypatch)
357 # First which("digg-pp-cli") (pre-install) -> None, npx -> present,
358 # then post-install which("digg-pp-cli") -> resolves.
359 calls = {"digg": 0}
360
361 def which_side_effect(cmd):
362 if cmd == "digg-pp-cli":
363 calls["digg"] += 1
364 return None if calls["digg"] == 1 else "/Users/me/go/bin/digg-pp-cli"
365 if cmd == "npx":
366 return "/opt/homebrew/bin/npx"
367 return None
368 mock_which.side_effect = which_side_effect
369 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
370
371 results = setup_wizard.run_auto_setup({})
372
373 # The wizard now also best-effort-installs the additional default-on
374 # Printing Press sources (arxiv/techmeme/trustpilot), so digg is one of
375 # several install calls rather than the only one. Argv[0] must be the
376 # *resolved* npx path (mirroring shutil.which's return value), not the
377 # bare "npx" string -- passing the bare name breaks Windows, where
378 # shutil.which resolves PATHEXT (npx.CMD) but subprocess.run does not.
379 mock_subproc.assert_any_call(
380 ["/opt/homebrew/bin/npx", "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
381 capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
382 )
383 assert results["digg_installed"] is True
384 assert results["digg_action"] == "installed"
385
386 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
387 @patch("subprocess.run")
388 @patch("shutil.which")
389 def test_digg_install_uses_resolved_windows_npx_path(
390 self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch
391 ):
392 """Regression for #904: a Windows-style resolved npx path (PATHEXT
393 resolution, e.g. npx.CMD) must be passed to subprocess.run verbatim --
394 not the bare string "npx", which fails with WinError 2 on Windows
395 because CreateProcess does not do PATHEXT resolution the way
396 shutil.which does."""
397 self._empty_home(tmp_path, monkeypatch)
398 calls = {"digg": 0}
399 windows_npx = r"C:\Program Files\nodejs\npx.CMD"
400
401 def which_side_effect(cmd):
402 if cmd == "digg-pp-cli":
403 calls["digg"] += 1
404 return None if calls["digg"] == 1 else r"C:\Users\me\.local\bin\digg-pp-cli"
405 if cmd == "npx":
406 return windows_npx
407 return None
408 mock_which.side_effect = which_side_effect
409 mock_subproc.return_value = MagicMock(returncode=0, stderr="")
410
411 results = setup_wizard.run_auto_setup({})
412
413 mock_subproc.assert_any_call(
414 [windows_npx, "-y", setup_wizard.PRINTING_PRESS_NPM, "install", "digg", "--cli-only"],
415 capture_output=True, text=True, timeout=setup_wizard.DIGG_INSTALL_TIMEOUT,
416 )
417 assert results["digg_installed"] is True
418 assert results["digg_action"] == "installed"
419
420 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
421 @patch("subprocess.run")
422 @patch("shutil.which")
423 def test_digg_install_fails_nonzero(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
424 """npx install returns non-zero -> install_failed with stderr."""
425 self._empty_home(tmp_path, monkeypatch)
426 def which_side_effect(cmd):
427 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
428 mock_which.side_effect = which_side_effect
429 mock_subproc.return_value = MagicMock(returncode=1, stderr="npm ERR! boom")
430
431 results = setup_wizard.run_auto_setup({})
432
433 assert results["digg_installed"] is False
434 assert results["digg_action"] == "install_failed"
435 assert "boom" in results["digg_stderr"]
436
437 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
438 @patch("shutil.which")
439 def test_digg_prior_install_off_path(self, mock_which, mock_extract, tmp_path, monkeypatch):
440 """pp-digg CLI at ~/.local/bin but not on PATH -> installed_off_path, no npx."""
441 self._empty_home(tmp_path, monkeypatch)
442 local_bin = tmp_path / ".local" / "bin"
443 local_bin.mkdir(parents=True)
444 binary = local_bin / "digg-pp-cli"
445 binary.write_text("#!/bin/sh\n")
446 binary.chmod(0o755)
447 mock_which.return_value = None
448
449 with patch("subprocess.run") as mock_subproc:
450 results = setup_wizard.run_auto_setup({})
451 mock_subproc.assert_not_called()
452
453 assert results["digg_installed"] is False
454 assert results["digg_action"] == "installed_off_path"
455 assert results["digg_path"] == str(binary)
456
457 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
458 @patch("subprocess.run")
459 @patch("shutil.which")
460 def test_digg_install_zero_but_not_on_path(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
461 """rc=0, binary at $HOME/.local/bin but not on PATH -> installed_off_path."""
462 self._empty_home(tmp_path, monkeypatch)
463 def which_side_effect(cmd):
464 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
465 mock_which.side_effect = which_side_effect
466
467 local_bin = tmp_path / ".local" / "bin"
468
469 def fake_install(*args, **kwargs):
470 local_bin.mkdir(parents=True, exist_ok=True)
471 binary = local_bin / "digg-pp-cli"
472 binary.write_text("#!/bin/sh\n")
473 binary.chmod(0o755)
474 return MagicMock(returncode=0, stderr="")
475 mock_subproc.side_effect = fake_install
476
477 results = setup_wizard.run_auto_setup({})
478
479 assert results["digg_installed"] is False
480 assert results["digg_action"] == "installed_off_path"
481 assert results["digg_path"] == str(local_bin / "digg-pp-cli")
482
483 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
484 @patch("subprocess.run")
485 @patch("shutil.which")
486 def test_digg_install_timeout_does_not_raise(self, mock_which, mock_subproc, mock_extract, tmp_path, monkeypatch):
487 """subprocess raising (e.g. timeout) -> install_failed, no exception escapes."""
488 self._empty_home(tmp_path, monkeypatch)
489 def which_side_effect(cmd):
490 return "/opt/homebrew/bin/npx" if cmd == "npx" else None
491 mock_which.side_effect = which_side_effect
492 mock_subproc.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=300)
493
494 results = setup_wizard.run_auto_setup({})
495
496 assert results["digg_installed"] is False
497 assert results["digg_action"] == "install_failed"
498
499
500 class TestWriteSetupConfig:
501 """Tests for write_setup_config()."""
502
503 def test_creates_new_env_file(self):
504 """Creates .env with SETUP_COMPLETE; omits FROM_BROWSER when unspecified.
505
506 With no detected browser we must NOT pin FROM_BROWSER=auto, because
507 that makes every later run probe Chrome and re-trigger the macOS
508 Keychain prompt. Leaving it unset applies the safe Firefox/Safari
509 default instead.
510 """
511 with tempfile.TemporaryDirectory() as tmpdir:
512 env_path = Path(tmpdir) / "subdir" / ".env"
513
514 result = setup_wizard.write_setup_config(env_path)
515
516 assert result is True
517 assert env_path.exists()
518 content = env_path.read_text()
519 assert "SETUP_COMPLETE=true" in content
520 assert "FROM_BROWSER" not in content
521
522 def test_appends_to_existing_file(self):
523 """Appends to existing .env without overwriting keys."""
524 with tempfile.TemporaryDirectory() as tmpdir:
525 env_path = Path(tmpdir) / ".env"
526 env_path.write_text("XAI_API_KEY=my-key\nAUTH_TOKEN=tok123\n")
527
528 result = setup_wizard.write_setup_config(env_path)
529
530 assert result is True
531 content = env_path.read_text()
532 # Original keys preserved
533 assert "XAI_API_KEY=my-key" in content
534 assert "AUTH_TOKEN=tok123" in content
535 # SETUP_COMPLETE appended; FROM_BROWSER omitted (no browser detected)
536 assert "SETUP_COMPLETE=true" in content
537 assert "FROM_BROWSER" not in content
538
539 def test_does_not_overwrite_existing_keys(self):
540 """If SETUP_COMPLETE or FROM_BROWSER already exist, don't duplicate."""
541 with tempfile.TemporaryDirectory() as tmpdir:
542 env_path = Path(tmpdir) / ".env"
543 env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
544
545 result = setup_wizard.write_setup_config(env_path)
546
547 assert result is True
548 content = env_path.read_text()
549 # Should only appear once
550 assert content.count("SETUP_COMPLETE") == 1
551 assert content.count("FROM_BROWSER") == 1
552 # Original value preserved
553 assert "FROM_BROWSER=firefox" in content
554
555 def test_custom_from_browser_value(self):
556 """Custom from_browser value is written."""
557 with tempfile.TemporaryDirectory() as tmpdir:
558 env_path = Path(tmpdir) / ".env"
559
560 result = setup_wizard.write_setup_config(env_path, from_browser="chrome")
561
562 assert result is True
563 content = env_path.read_text()
564 assert "FROM_BROWSER=chrome" in content
565
566 def test_creates_parent_directories(self):
567 """Creates parent directories if they don't exist."""
568 with tempfile.TemporaryDirectory() as tmpdir:
569 env_path = Path(tmpdir) / "a" / "b" / "c" / ".env"
570
571 result = setup_wizard.write_setup_config(env_path)
572
573 assert result is True
574 assert env_path.exists()
575
576 def test_handles_file_without_trailing_newline(self):
577 """Appends correctly when existing file has no trailing newline."""
578 with tempfile.TemporaryDirectory() as tmpdir:
579 env_path = Path(tmpdir) / ".env"
580 env_path.write_text("EXISTING_KEY=value") # no trailing newline
581
582 result = setup_wizard.write_setup_config(env_path, from_browser="firefox")
583
584 assert result is True
585 content = env_path.read_text()
586 # Should have newline separator
587 lines = content.strip().split("\n")
588 assert len(lines) == 3
589 assert lines[0] == "EXISTING_KEY=value"
590 assert "SETUP_COMPLETE=true" in lines[1]
591
592
593 class TestWriteApiKey:
594 """Tests for write_api_key() — persisting the ScrapeCreators signup key."""
595
596 def test_writes_key_with_secret_permissions(self):
597 """Key is written and the file is 0o600 (owner read/write only)."""
598 with tempfile.TemporaryDirectory() as tmpdir:
599 env_path = Path(tmpdir) / "subdir" / ".env"
600
601 result = setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
602
603 assert result is True
604 assert env_path.exists()
605 assert "SCRAPECREATORS_API_KEY=sc_live_abcdef123456" in env_path.read_text()
606 assert (env_path.stat().st_mode & 0o777) == 0o600
607
608 def test_value_round_trips_through_env_loader(self):
609 """Persisted key reloads to the exact original value."""
610 from lib import env as env_mod
611 with tempfile.TemporaryDirectory() as tmpdir:
612 env_path = Path(tmpdir) / ".env"
613
614 setup_wizard.write_api_key(env_path, "sc_live_abcdef123456")
615
616 loaded = env_mod.load_env_file(env_path)
617 assert loaded["SCRAPECREATORS_API_KEY"] == "sc_live_abcdef123456"
618
619 def test_idempotent_when_key_already_present(self):
620 """If the key already exists, do not duplicate or overwrite it."""
621 with tempfile.TemporaryDirectory() as tmpdir:
622 env_path = Path(tmpdir) / ".env"
623 env_path.write_text("SCRAPECREATORS_API_KEY=existing_key\n")
624
625 result = setup_wizard.write_api_key(env_path, "sc_new_value")
626
627 assert result is True
628 content = env_path.read_text()
629 assert content.count("SCRAPECREATORS_API_KEY") == 1
630 assert "existing_key" in content
631 assert "sc_new_value" not in content
632
633 def test_appends_without_clobbering_other_keys(self):
634 """Existing unrelated keys are preserved."""
635 with tempfile.TemporaryDirectory() as tmpdir:
636 env_path = Path(tmpdir) / ".env"
637 env_path.write_text("SETUP_COMPLETE=true\nFROM_BROWSER=firefox\n")
638
639 setup_wizard.write_api_key(env_path, "sc_key_xyz")
640
641 content = env_path.read_text()
642 assert "SETUP_COMPLETE=true" in content
643 assert "FROM_BROWSER=firefox" in content
644 assert "SCRAPECREATORS_API_KEY=sc_key_xyz" in content
645
646 def test_value_with_whitespace_is_quoted(self):
647 """A pathological value with whitespace is quoted so it round-trips."""
648 from lib import env as env_mod
649 with tempfile.TemporaryDirectory() as tmpdir:
650 env_path = Path(tmpdir) / ".env"
651
652 setup_wizard.write_api_key(env_path, "key with space")
653
654 content = env_path.read_text()
655 assert 'SCRAPECREATORS_API_KEY="key with space"' in content
656 assert env_mod.load_env_file(env_path)["SCRAPECREATORS_API_KEY"] == "key with space"
657
658 def test_empty_key_returns_false_and_writes_nothing(self):
659 """An empty api_key persists nothing and reports failure."""
660 with tempfile.TemporaryDirectory() as tmpdir:
661 env_path = Path(tmpdir) / ".env"
662
663 assert setup_wizard.write_api_key(env_path, "") is False
664 assert not env_path.exists()
665
666 def test_unwritable_target_returns_false(self):
667 """Unwritable target dir -> False, no exception escapes."""
668 with tempfile.TemporaryDirectory() as tmpdir:
669 ro_dir = Path(tmpdir) / "ro"
670 ro_dir.mkdir()
671 ro_dir.chmod(0o500) # no write
672 try:
673 result = setup_wizard.write_api_key(ro_dir / "sub" / ".env", "sc_key")
674 assert result is False
675 finally:
676 ro_dir.chmod(0o700) # restore so tempdir cleanup succeeds
677
678
679 class TestMaskApiKey:
680 """Tests for mask_api_key() — non-secret display form."""
681
682 def test_masks_long_key(self):
683 masked = setup_wizard.mask_api_key("sc_live_abcdef123456")
684 assert "abcdef" not in masked
685 assert masked.endswith("3456")
686 assert masked.startswith("sc_")
687
688 def test_short_key_collapses_to_placeholder(self):
689 assert setup_wizard.mask_api_key("short") == "sc_…"
690
691 def test_empty_key_collapses_to_placeholder(self):
692 assert setup_wizard.mask_api_key("") == "sc_…"
693
694
695 class TestCookieExtractionBrowsers:
696 """Tests for env.cookie_extraction_browsers() — the shared browser policy."""
697
698 def test_default_disables_extraction(self):
699 """FROM_BROWSER unset -> no browser-cookie reads."""
700 from lib import env
701 browsers = env.cookie_extraction_browsers({})
702 assert browsers == []
703
704 def test_off_disables_extraction(self):
705 from lib import env
706 assert env.cookie_extraction_browsers({"FROM_BROWSER": "off"}) == []
707
708 def test_auto_opts_into_chrome(self):
709 from lib import env
710 assert "chrome" in env.cookie_extraction_browsers({"FROM_BROWSER": "auto"})
711
712 def test_specific_browser(self):
713 from lib import env
714 assert env.cookie_extraction_browsers({"FROM_BROWSER": "chrome"}) == ["chrome"]
715
716
717 class TestWizardDoesNotProbeChromeByDefault:
718 """Regression: first-run setup must not silently read Chrome cookies."""
719
720 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
721 @patch("shutil.which", return_value=None)
722 def test_default_run_never_requests_chrome(self, _mock_which, mock_extract):
723 setup_wizard.run_auto_setup({})
724 requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
725 assert requested_browsers == set()
726
727 @patch("lib.cookie_extract.extract_cookies_with_source", return_value=None)
728 @patch("shutil.which", return_value=None)
729 def test_from_browser_auto_does_request_chrome(self, _mock_which, mock_extract):
730 setup_wizard.run_auto_setup({"FROM_BROWSER": "auto"}, allow_browser_cookies=True)
731 requested_browsers = {call.args[0] for call in mock_extract.call_args_list}
732 assert "chrome" in requested_browsers
733
734 @patch("lib.cookie_extract.extract_cookies_with_source")
735 @patch("shutil.which", return_value=None)
736 def test_from_browser_off_skips_extraction(self, _mock_which, mock_extract):
737 results = setup_wizard.run_auto_setup({"FROM_BROWSER": "off"})
738 mock_extract.assert_not_called()
739 assert results["cookies_found"] == {}
740
741
742 class TestGetSetupStatusText:
743 """Tests for get_setup_status_text()."""
744
745 def test_with_cookies_and_ytdlp(self):
746 """Status text mentions found cookies and yt-dlp."""
747 results = {
748 "cookies_found": {"x": "chrome"},
749 "browser_cookie_scan_attempted": True,
750 "ytdlp_installed": True,
751 "ytdlp_action": "already_installed",
752 "env_written": True,
753 }
754 text = setup_wizard.get_setup_status_text(results)
755 assert "X cookies found in chrome" in text
756 assert "yt-dlp already installed" in text
757 assert "Configuration saved" in text
758
759 def test_skipped_cookie_scan_does_not_claim_x_is_missing(self):
760 """A consent-safe skip reports setup work without an X unlock nudge."""
761 results = {
762 "cookies_found": {},
763 "browser_cookie_scan_attempted": False,
764 "ytdlp_installed": False,
765 "ytdlp_action": "no_homebrew",
766 "env_written": False,
767 }
768 text = setup_wizard.get_setup_status_text(results)
769 assert "browser cookies" not in text.lower()
770 assert "X/Twitter" not in text
771 assert "Install Homebrew first" in text
772
773 def test_consented_scan_with_no_match_stays_non_promotional(self):
774 results = {
775 "cookies_found": {},
776 "browser_cookie_scan_attempted": True,
777 "ytdlp_installed": True,
778 "ytdlp_action": "already_installed",
779 "env_written": False,
780 }
781 text = setup_wizard.get_setup_status_text(results)
782 assert "browser cookies" not in text.lower()
783 assert "X/Twitter" not in text
784
785 def test_status_text_installed(self):
786 """Status text for freshly installed yt-dlp."""
787 results = {
788 "cookies_found": {},
789 "ytdlp_installed": True,
790 "ytdlp_action": "installed",
791 "env_written": False,
792 }
793 text = setup_wizard.get_setup_status_text(results)
794 assert "Installed yt-dlp via Homebrew" in text
795
796 def test_status_text_install_failed(self):
797 """Status text for failed yt-dlp install."""
798 results = {
799 "cookies_found": {},
800 "ytdlp_installed": False,
801 "ytdlp_action": "install_failed",
802 "env_written": False,
803 }
804 text = setup_wizard.get_setup_status_text(results)
805 assert "yt-dlp install failed" in text
806 assert "manually" in text
807
808 def test_status_text_digg_installed(self):
809 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
810 "digg_action": "installed", "env_written": False}
811 text = setup_wizard.get_setup_status_text(results)
812 assert "Installed Digg CLI" in text
813
814 def test_status_text_digg_already_installed(self):
815 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
816 "digg_action": "already_installed", "env_written": False}
817 text = setup_wizard.get_setup_status_text(results)
818 assert "Digg CLI already installed" in text
819
820 def test_status_text_digg_install_failed(self):
821 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
822 "digg_action": "install_failed", "env_written": False}
823 text = setup_wizard.get_setup_status_text(results)
824 assert "Digg CLI install failed" in text
825 assert "printing-press-library" in text
826
827 def test_status_text_digg_installed_off_path(self):
828 home = Path.home()
829 digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
830 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
831 "digg_action": "installed_off_path",
832 "digg_path": digg_path,
833 "env_written": False}
834 text = setup_wizard.get_setup_status_text(results)
835 assert "not on PATH" in text
836 assert "$HOME/.local/bin" in text
837 assert "now active" not in text.lower()
838
839 def test_status_text_digg_installed_off_path_legacy_go_bin(self):
840 """PATH hint names the actual install dir as $HOME-relative, not ~/.local/bin."""
841 home = Path.home()
842 digg_path = str(home / "go" / "bin" / "digg-pp-cli")
843 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
844 "digg_action": "installed_off_path",
845 "digg_path": digg_path,
846 "env_written": False}
847 text = setup_wizard.get_setup_status_text(results)
848 assert "$HOME/go/bin" in text
849 assert ".local/bin" not in text
850
851 def test_status_text_digg_installed_off_path_missing_path(self):
852 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
853 "digg_action": "installed_off_path",
854 "env_written": False}
855 text = setup_wizard.get_setup_status_text(results)
856 assert "not on PATH" in text
857 assert "add its install directory to PATH" in text
858
859 def test_status_text_digg_installed_off_path_empty_path(self):
860 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
861 "digg_action": "installed_off_path",
862 "digg_path": "",
863 "env_written": False}
864 text = setup_wizard.get_setup_status_text(results)
865 assert "add its install directory to PATH" in text
866
867 def test_digg_bin_dir_hint_windows_returns_absolute_parent(self):
868 home = Path.home()
869 digg_path = str(home / ".local" / "bin" / "digg-pp-cli")
870 expected = str(home / ".local" / "bin")
871 with patch.object(setup_wizard.os, "name", "nt"):
872 assert setup_wizard._digg_bin_dir_hint(digg_path) == expected
873
874 def test_status_text_digg_no_npx(self):
875 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
876 "digg_action": "no_npx", "env_written": False}
877 text = setup_wizard.get_setup_status_text(results)
878 assert "Digg CLI not installed" in text
879
880 def test_status_text_digg_absent_key_renders(self):
881 """No digg_action key (defensive) -> no Digg line, no error."""
882 results = {"cookies_found": {}, "ytdlp_action": "already_installed",
883 "env_written": False}
884 text = setup_wizard.get_setup_status_text(results)
885 assert "Digg" not in text
886
887
888 class TestSetupSubcommand:
889 """Tests for setup subcommand detection in argument parsing."""
890
891 def test_setup_detected_as_topic(self):
892 """The word 'setup' is treated as the setup subcommand."""
893 # Simulate what argparse produces
894 import argparse
895 parser = argparse.ArgumentParser()
896 parser.add_argument("topic", nargs="*")
897 args = parser.parse_args(["setup"])
898 topic = " ".join(args.topic) if args.topic else None
899 assert topic is not None
900 assert topic.strip().lower() == "setup"
901
902 def test_normal_topic_not_setup(self):
903 """A normal topic is not confused with setup."""
904 import argparse
905 parser = argparse.ArgumentParser()
906 parser.add_argument("topic", nargs="*")
907 args = parser.parse_args(["AI", "video", "tools"])
908 topic = " ".join(args.topic) if args.topic else None
909 assert topic.strip().lower() != "setup"
910
911
912 class TestBrightDataStatusHonesty:
913 """U5/R11: setup must never claim active unless the engine gate passes."""
914
915 def _patched(self, *, installed, credentialed, off_path=None):
916 available = installed and credentialed
917 return (
918 patch.object(setup_wizard.brightdata, "is_installed", return_value=installed),
919 patch.object(setup_wizard.brightdata, "has_credentials", return_value=credentialed),
920 patch.object(setup_wizard.brightdata, "is_available", return_value=available),
921 patch.object(setup_wizard, "_brightdata_off_path_binary", return_value=off_path),
922 )
923
924 def test_on_path_and_credentialed_reports_engine_active(self):
925 a, b, c, d = self._patched(installed=True, credentialed=True)
926 with a, b, c, d:
927 status = setup_wizard.brightdata_status({})
928 assert status["action"] == "already_installed"
929 assert status["authenticated"] is True
930 assert status["engine_active"] is True
931
932 def test_on_path_without_credentials_is_not_active_and_names_login(self):
933 a, b, c, d = self._patched(installed=True, credentialed=False)
934 with a, b, c, d:
935 status = setup_wizard.brightdata_status({})
936 assert status["action"] == "already_installed"
937 assert status["authenticated"] is False
938 assert status["engine_active"] is False
939 assert "brightdata login" in status["hint"]
940
941 def test_off_path_binary_is_reported_with_its_path(self):
942 """The Hermes/OpenClaw failure mode: on disk, invisible to the engine."""
943 a, b, c, d = self._patched(
944 installed=False, credentialed=True, off_path="/Users/x/.npm-global/bin/brightdata"
945 )
946 with a, b, c, d:
947 status = setup_wizard.brightdata_status({})
948 assert status["action"] == "installed_off_path"
949 assert status["engine_active"] is False
950 assert status["path"] == "/Users/x/.npm-global/bin/brightdata"
951 assert "PATH" in status["hint"]
952
953 def test_absent_binary_recommends_but_never_installs(self):
954 a, b, c, d = self._patched(installed=False, credentialed=False)
955 with a, b, c, d, patch.object(setup_wizard.subprocess, "run") as run:
956 status = setup_wizard.brightdata_status({})
957 assert status["action"] == "not_installed"
958 assert status["engine_active"] is False
959 run.assert_not_called()
960
961 def test_brightdata_is_excluded_from_auto_installed_pp_sources(self):
962 slugs = {slug for _, slug, _ in setup_wizard.PP_DEFAULT_SOURCES}
963 assert "brightdata" not in slugs
964
965
966 class TestBrightDataSetupSurface:
967 """The three states must be visible somewhere, or the honesty is moot."""
968
969 def _text(self, status):
970 return setup_wizard.get_setup_status_text({
971 "cookies_found": {}, "ytdlp_installed": True,
972 "ytdlp_action": "already_installed", "digg_installed": True,
973 "digg_action": "already_installed", "pp_sources": {},
974 "brightdata": status, "env_written": False,
975 })
976
977 def test_active_state_is_reported(self):
978 text = self._text({"action": "already_installed", "engine_active": True})
979 assert "Bright Data CLI ready" in text
980
981 def test_installed_but_not_logged_in_names_the_fix(self):
982 text = self._text({"action": "already_installed", "engine_active": False})
983 assert "brightdata login" in text
984
985 def test_off_path_reports_the_path_and_the_fix(self):
986 text = self._text({
987 "action": "installed_off_path", "engine_active": False,
988 "path": "/Users/x/.npm-global/bin/brightdata",
989 })
990 assert "/Users/x/.npm-global/bin/brightdata" in text
991 assert "PATH" in text
992
993 def test_absent_offers_the_install_command_without_running_it(self):
994 text = self._text({"action": "not_installed", "engine_active": False})
995 assert "npm i -g @brightdata/cli" in text
996
996 lines PYTHON