返回 last30days-skill
test_cli_v3.py
根目录 / tests / test_cli_v3.py
1 # fmt: off
2 import contextlib
3 import json
4 import io
5 import os
6 import shutil
7 import tempfile
8 import subprocess
9 import sys
10 import types
11 import unittest
12 from contextlib import redirect_stderr, redirect_stdout
13 from datetime import datetime
14 from pathlib import Path
15 from unittest import mock
16
17 import last30days as cli
18 from lib import schema
19
20 REPO_ROOT = Path(__file__).resolve().parents[1]
21
22
23 class CliV3Tests(unittest.TestCase):
24 def make_report(self, topic: str = "OpenClaw vs NanoClaw") -> schema.Report:
25 return schema.Report(
26 topic=topic,
27 range_from="2026-02-14",
28 range_to="2026-03-16",
29 generated_at="2026-03-16T00:00:00+00:00",
30 provider_runtime=schema.ProviderRuntime(
31 reasoning_provider="gemini",
32 planner_model="gemini-3.1-flash-lite",
33 rerank_model="gemini-3.1-flash-lite",
34 ),
35 query_plan=schema.QueryPlan(
36 intent="comparison",
37 freshness_mode="balanced_recent",
38 cluster_mode="debate",
39 raw_topic=topic,
40 subqueries=[
41 schema.SubQuery(
42 label="primary",
43 search_query=topic.lower(),
44 ranking_query=f"What are people saying about {topic}?",
45 sources=["grounding"],
46 )
47 ],
48 source_weights={"grounding": 1.0},
49 ),
50 clusters=[],
51 ranked_candidates=[],
52 items_by_source={"grounding": []},
53 errors_by_source={},
54 )
55
56 def test_mock_json_cli(self):
57 result = subprocess.run(
58 [sys.executable, "skills/last30days/scripts/last30days.py", "test topic", "--mock", "--emit=json"],
59 cwd=REPO_ROOT,
60 capture_output=True,
61 text=True,
62 encoding="utf-8",
63 check=False,
64 )
65 self.assertEqual(0, result.returncode, result.stderr)
66 payload = json.loads(result.stdout)
67 self.assertEqual("1.3", payload["schema_version"])
68 self.assertEqual("test topic", payload["query"])
69 self.assertIn("results", payload)
70 self.assertIn("clusters", payload)
71 self.assertIn("source_status", payload)
72
73 def test_invalid_plan_json_exits_nonzero(self):
74 """Malformed --plan JSON must fail fast, not silently fall back to the
75 internal planner and burn a paid run the user did not ask for."""
76 result = subprocess.run(
77 [
78 sys.executable,
79 "skills/last30days/scripts/last30days.py",
80 "test topic",
81 "--mock",
82 "--emit=json",
83 "--plan",
84 "{not valid json",
85 ],
86 cwd=REPO_ROOT,
87 capture_output=True,
88 text=True,
89 encoding="utf-8",
90 check=False,
91 )
92 self.assertEqual(2, result.returncode, result.stderr)
93 self.assertIn("Invalid --plan JSON", result.stderr)
94
95 def test_invalid_plan_structure_exits_nonzero_without_fallback(self):
96 result = subprocess.run(
97 [
98 sys.executable,
99 "skills/last30days/scripts/last30days.py",
100 "test topic",
101 "--mock",
102 "--emit=json",
103 "--plan",
104 json.dumps({"queries": {"web": ["Berlin"]}}),
105 ],
106 cwd=REPO_ROOT,
107 capture_output=True,
108 text=True,
109 encoding="utf-8",
110 check=False,
111 )
112 self.assertEqual(2, result.returncode, result.stderr)
113 self.assertIn("Invalid --plan schema", result.stderr)
114 self.assertNotIn("fallback-plan", result.stderr)
115
116 def test_parse_search_flag_normalizes_aliases_and_dedupes(self):
117 self.assertEqual(
118 ["grounding", "reddit", "hackernews"],
119 cli.parse_search_flag("web, reddit, hn, web"),
120 )
121
122 def test_parse_search_flag_accepts_optional_social_sources(self):
123 self.assertEqual(
124 ["threads", "pinterest"],
125 cli.parse_search_flag("threads, pinterest"),
126 )
127
128 def test_explicit_threads_search_uses_scrapecreators_key_without_include_sources(self):
129 available = cli.pipeline.available_sources(
130 {"SCRAPECREATORS_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
131 requested_sources=["threads"],
132 )
133 self.assertIn("threads", available)
134
135 def test_explicit_perplexity_search_uses_openrouter_fallback(self):
136 available = cli.pipeline.available_sources(
137 {"OPENROUTER_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
138 requested_sources=["perplexity"],
139 )
140 self.assertIn("perplexity", available)
141
142 def test_explicit_perplexity_search_uses_direct_key_without_include_sources(self):
143 available = cli.pipeline.available_sources(
144 {"PERPLEXITY_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
145 requested_sources=["perplexity"],
146 )
147 self.assertIn("perplexity", available)
148
149 def test_parse_search_flag_rejects_invalid_or_empty_inputs(self):
150 with self.assertRaises(SystemExit):
151 cli.parse_search_flag("unknown")
152 with self.assertRaises(SystemExit):
153 cli.parse_search_flag(" , ")
154
155 def test_resolve_requested_sources_flag_wins_over_config_default(self):
156 sources = cli.resolve_requested_sources(
157 "reddit", {"LAST30DAYS_DEFAULT_SEARCH": "x,youtube"},
158 )
159 self.assertEqual(["reddit"], sources)
160
161 def test_resolve_requested_sources_falls_back_to_config_default(self):
162 sources = cli.resolve_requested_sources(
163 None, {"LAST30DAYS_DEFAULT_SEARCH": "web, reddit, hn"},
164 )
165 self.assertEqual(["grounding", "reddit", "hackernews"], sources)
166
167 def test_resolve_requested_sources_none_when_neither_set(self):
168 self.assertIsNone(cli.resolve_requested_sources(None, {}))
169 self.assertIsNone(
170 cli.resolve_requested_sources(None, {"LAST30DAYS_DEFAULT_SEARCH": ""})
171 )
172 self.assertIsNone(
173 cli.resolve_requested_sources(None, {"LAST30DAYS_DEFAULT_SEARCH": " "})
174 )
175
176 def test_resolve_requested_sources_invalid_config_default_names_env_var(self):
177 with self.assertRaises(SystemExit) as exc:
178 cli.resolve_requested_sources(
179 None, {"LAST30DAYS_DEFAULT_SEARCH": "notasource"},
180 )
181 self.assertIn("LAST30DAYS_DEFAULT_SEARCH", str(exc.exception))
182
183 def test_deep_research_preserves_default_source_selection(self):
184 self.assertIsNone(cli.add_deep_research_source(None))
185
186 def test_deep_research_extends_an_explicit_source_selection(self):
187 self.assertEqual(
188 ["reddit", "perplexity"],
189 cli.add_deep_research_source(["reddit"]),
190 )
191 self.assertEqual(
192 ["reddit", "perplexity"],
193 cli.add_deep_research_source(["reddit", "perplexity"]),
194 )
195
196 def test_deep_research_enables_exact_include_token(self):
197 config = {"INCLUDE_SOURCES": "notperplexity,reddit"}
198
199 cli.enable_deep_research_source(config)
200
201 self.assertEqual(
202 ["notperplexity", "reddit", "perplexity"],
203 config["INCLUDE_SOURCES"].split(","),
204 )
205
206 def test_deep_research_rejects_exact_exclusion(self):
207 config = {"EXCLUDE_SOURCES": "reddit,Perplexity"}
208
209 with self.assertRaisesRegex(
210 ValueError,
211 "conflicts with EXCLUDE_SOURCES=perplexity",
212 ):
213 cli.enable_deep_research_source(config)
214
215 def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
216 parser = cli.build_parser()
217 args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
218 self.assertEqual(7, args.lookback_days)
219 self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
220 self.assertEqual([], extra)
221
222 def test_build_parser_accepts_web_backend_keyless(self):
223 """Regression for #905: CONFIGURATION.md documents --web-backend=keyless
224 to force the zero-key floor, but the choices list rejected it."""
225 parser = cli.build_parser()
226 args, extra = parser.parse_known_args(["--web-backend", "keyless", "biosecurity"])
227 self.assertEqual("keyless", args.web_backend)
228 self.assertEqual([], extra)
229
230 def test_deep_research_help_keeps_openrouter_fallback(self):
231 parser = cli.build_parser()
232 action = next(
233 candidate
234 for candidate in parser._actions
235 if "--deep-research" in candidate.option_strings
236 )
237 self.assertIn("PERPLEXITY_API_KEY", action.help)
238 self.assertIn("OPENROUTER_API_KEY", action.help)
239 self.assertIn("cannot be combined with competitor or vs-mode", action.help)
240
241 def test_deep_research_rejects_modes_without_a_positional_topic(self):
242 for argv in (
243 ["last30days.py", "--discover", "agents", "--deep-research"],
244 ["last30days.py", "--drill", "cluster-1", "--deep-research"],
245 ):
246 with self.subTest(argv=argv), mock.patch.object(
247 cli.env,
248 "get_config",
249 return_value={},
250 ), mock.patch.object(
251 cli,
252 "_run_discover",
253 ) as discover_mock, mock.patch.object(
254 cli,
255 "_run_drill",
256 ) as drill_mock, mock.patch.dict(
257 os.environ,
258 {"LAST30DAYS_SKIP_PREFLIGHT": "1"},
259 clear=False,
260 ), mock.patch.object(sys, "argv", argv):
261 stderr = io.StringIO()
262 with redirect_stderr(stderr):
263 rc = cli.main()
264
265 self.assertEqual(2, rc)
266 discover_mock.assert_not_called()
267 drill_mock.assert_not_called()
268 self.assertIn("requires a normal positional topic", stderr.getvalue())
269
270 def test_deep_research_rejects_competitor_fanout_before_pipeline_run(self):
271 diag = {
272 "available_sources": ["perplexity"],
273 "providers": {"google": False, "openai": False, "xai": False},
274 "x_backend": None,
275 "bird_installed": True,
276 "bird_authenticated": False,
277 "bird_username": None,
278 "native_web_backend": None,
279 }
280 with mock.patch.object(
281 cli.env,
282 "get_config",
283 return_value={"PERPLEXITY_API_KEY": "pplx-test"},
284 ), mock.patch.object(
285 cli.pipeline,
286 "diagnose",
287 return_value=diag,
288 ) as diagnose_mock, mock.patch.object(
289 cli.pipeline,
290 "run",
291 ) as run_mock, mock.patch.object(
292 cli.ui,
293 "ProgressDisplay",
294 return_value=mock.Mock(),
295 ), mock.patch.object(
296 sys,
297 "argv",
298 [
299 "last30days.py",
300 "Alpha",
301 "vs",
302 "Beta",
303 "--mock",
304 "--deep-research",
305 ],
306 ):
307 stderr = io.StringIO()
308 with redirect_stderr(stderr):
309 rc = cli.main()
310
311 self.assertEqual(2, rc)
312 diagnose_mock.assert_not_called()
313 run_mock.assert_not_called()
314 self.assertIn(
315 "one paid Deep Research run per user action",
316 stderr.getvalue(),
317 )
318
319 def test_openrouter_deep_research_bypasses_hosted_and_adds_source(self):
320 report = self.make_report(topic="why AI safety matters")
321 diag = {
322 "available_sources": ["reddit", "perplexity"],
323 "providers": {"google": False, "openai": False, "xai": False},
324 "x_backend": None,
325 "bird_installed": True,
326 "bird_authenticated": False,
327 "bird_username": None,
328 "native_web_backend": None,
329 }
330 with mock.patch.object(
331 cli.env,
332 "get_config",
333 return_value={"OPENROUTER_API_KEY": "openrouter-test"},
334 ), mock.patch.object(
335 cli.env,
336 "read_secret_env",
337 return_value="hosted-test-key",
338 ), mock.patch.object(
339 cli.pipeline,
340 "diagnose",
341 return_value=diag,
342 ), mock.patch.object(
343 cli.pipeline,
344 "run",
345 return_value=report,
346 ) as run_mock, mock.patch(
347 "lib.hosted.run_hosted",
348 ) as hosted_mock, mock.patch.object(
349 cli.ui,
350 "ProgressDisplay",
351 return_value=mock.Mock(),
352 ), mock.patch.object(
353 cli,
354 "emit_output",
355 return_value="# rendered",
356 ), mock.patch.dict(
357 os.environ,
358 {
359 "LAST30DAYS_API_BASE": "https://hosted.example.test",
360 "LAST30DAYS_SKIP_PREFLIGHT": "1",
361 },
362 clear=False,
363 ), mock.patch.object(
364 sys,
365 "argv",
366 [
367 "last30days.py",
368 "why",
369 "AI",
370 "safety",
371 "matters",
372 "--deep-research",
373 "--search",
374 "reddit",
375 ],
376 ):
377 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
378 rc = cli.main()
379
380 self.assertEqual(0, rc)
381 hosted_mock.assert_not_called()
382 requested_sources = run_mock.call_args.kwargs["requested_sources"]
383 self.assertEqual(["reddit", "perplexity"], requested_sources)
384 self.assertTrue(run_mock.call_args.kwargs["config"]["_deep_research"])
385
386 def _run_perplexity_search_type_cli(self, argv_extra, config, *, hosted):
387 report = self.make_report(topic="rust releases")
388 diag = {
389 "available_sources": ["reddit"],
390 "providers": {"google": False, "openai": False, "xai": False},
391 "x_backend": None,
392 "bird_installed": True,
393 "bird_authenticated": False,
394 "bird_username": None,
395 "native_web_backend": None,
396 }
397 environ = {"LAST30DAYS_SKIP_PREFLIGHT": "1"}
398 if hosted:
399 environ["LAST30DAYS_API_BASE"] = "https://hosted.example.test"
400 stderr = io.StringIO()
401 with mock.patch.object(
402 cli.env, "get_config", return_value=dict(config),
403 ), mock.patch.object(
404 cli.env, "read_secret_env",
405 return_value="hosted-test-key" if hosted else None,
406 ), mock.patch.object(
407 cli.pipeline, "diagnose", return_value=diag,
408 ), mock.patch.object(
409 cli.pipeline, "run", return_value=report,
410 ) as run_mock, mock.patch(
411 "lib.hosted.run_hosted", return_value=0,
412 ) as hosted_mock, mock.patch(
413 "lib.resolve.auto_resolve", return_value={},
414 ), mock.patch.object(
415 cli.ui, "ProgressDisplay", return_value=mock.Mock(),
416 ), mock.patch.object(
417 cli, "emit_output", return_value="# rendered",
418 ), mock.patch.dict(
419 os.environ, environ, clear=False,
420 ), mock.patch.object(
421 sys, "argv", ["last30days.py", "rust", "releases", *argv_extra],
422 ):
423 if not hosted:
424 os.environ.pop("LAST30DAYS_API_BASE", None)
425 with redirect_stdout(io.StringIO()), redirect_stderr(stderr):
426 rc = cli.main()
427 return rc, run_mock, hosted_mock, stderr.getvalue()
428
429 def test_perplexity_search_type_flag_bypasses_hosted_with_one_notice(self):
430 rc, run_mock, hosted_mock, err = self._run_perplexity_search_type_cli(
431 ["--perplexity-search-type", "fast"],
432 {"PERPLEXITY_API_KEY": "pplx-test"},
433 hosted=True,
434 )
435
436 self.assertEqual(0, rc)
437 hosted_mock.assert_not_called()
438 run_mock.assert_called_once()
439 config = run_mock.call_args.kwargs["config"]
440 self.assertEqual("fast", config["LAST30DAYS_PERPLEXITY_SEARCH_TYPE"])
441 self.assertEqual(1, err.count("--perplexity-search-type"))
442 self.assertIn("bypassing the hosted backend", err)
443 self.assertNotIn("does not apply LAST30DAYS_PERPLEXITY_SEARCH_TYPE", err)
444
445 def test_perplexity_search_type_flag_without_direct_key_stays_hosted(self):
446 rc, run_mock, hosted_mock, err = self._run_perplexity_search_type_cli(
447 ["--perplexity-search-type", "fast"],
448 {"OPENROUTER_API_KEY": "or-test"},
449 hosted=True,
450 )
451
452 self.assertEqual(0, rc)
453 hosted_mock.assert_called_once()
454 run_mock.assert_not_called()
455 self.assertEqual(1, err.count("no direct PERPLEXITY_API_KEY is configured"))
456 self.assertNotIn("bypassing the hosted backend", err)
457
458 def test_search_type_flag_without_direct_key_matches_no_flag_routing(self):
459 # Without a direct key the flag cannot apply, so hosted routing and its
460 # local-only option checks must match a run without the flag.
461 for extra in (["--x-posts", "https://x.com/a/status/1"], ["--emit=json", "--json-profile=agent"]):
462 plain_rc, plain_run, plain_hosted, _ = self._run_perplexity_search_type_cli(
463 extra, {"OPENROUTER_API_KEY": "or-test"}, hosted=True,
464 )
465 flag_rc, flag_run, flag_hosted, _ = self._run_perplexity_search_type_cli(
466 ["--perplexity-search-type", "fast", *extra],
467 {"OPENROUTER_API_KEY": "or-test"},
468 hosted=True,
469 )
470 self.assertEqual(plain_rc, flag_rc, extra)
471 self.assertEqual(plain_run.call_count, flag_run.call_count, extra)
472 self.assertEqual(plain_hosted.call_count, flag_hosted.call_count, extra)
473
474 def test_env_only_perplexity_search_type_stays_hosted_with_one_note(self):
475 rc, run_mock, hosted_mock, err = self._run_perplexity_search_type_cli(
476 [], {"LAST30DAYS_PERPLEXITY_SEARCH_TYPE": "fast"}, hosted=True,
477 )
478
479 self.assertEqual(0, rc)
480 hosted_mock.assert_called_once()
481 run_mock.assert_not_called()
482 self.assertEqual(
483 1,
484 err.count("hosted backend does not apply LAST30DAYS_PERPLEXITY_SEARCH_TYPE"),
485 )
486 self.assertNotIn("bypassing the hosted backend", err)
487
488 def test_hosted_without_perplexity_search_type_is_unchanged(self):
489 rc, run_mock, hosted_mock, err = self._run_perplexity_search_type_cli(
490 [], {}, hosted=True,
491 )
492
493 self.assertEqual(0, rc)
494 hosted_mock.assert_called_once()
495 run_mock.assert_not_called()
496 self.assertNotIn("search type", err.lower())
497 self.assertNotIn("SEARCH_TYPE", err)
498
499 def test_perplexity_search_type_flag_does_not_enable_perplexity(self):
500 _, baseline_run, _, _ = self._run_perplexity_search_type_cli(
501 [], {"PERPLEXITY_API_KEY": "pplx-test"}, hosted=False,
502 )
503 rc, flagged_run, _, err = self._run_perplexity_search_type_cli(
504 ["--perplexity-search-type", "fast"],
505 {"PERPLEXITY_API_KEY": "pplx-test"},
506 hosted=False,
507 )
508
509 self.assertEqual(0, rc)
510 self.assertEqual(
511 baseline_run.call_args.kwargs["requested_sources"],
512 flagged_run.call_args.kwargs["requested_sources"],
513 )
514 self.assertNotIn("perplexity", flagged_run.call_args.kwargs["requested_sources"] or [])
515 config = flagged_run.call_args.kwargs["config"]
516 self.assertEqual("fast", config["LAST30DAYS_PERPLEXITY_SEARCH_TYPE"])
517 self.assertNotIn("INCLUDE_SOURCES", config)
518 self.assertNotIn("bypassing the hosted backend", err)
519
520 def test_perplexity_search_type_flag_overrides_env_value(self):
521 _, run_mock, _, _ = self._run_perplexity_search_type_cli(
522 ["--perplexity-search-type", "web"],
523 {"LAST30DAYS_PERPLEXITY_SEARCH_TYPE": "fast"},
524 hosted=False,
525 )
526
527 config = run_mock.call_args.kwargs["config"]
528 self.assertEqual("web", config["LAST30DAYS_PERPLEXITY_SEARCH_TYPE"])
529
530 def test_build_parser_still_accepts_other_web_backend_values(self):
531 parser = cli.build_parser()
532 for value in ("auto", "brave", "exa", "serper", "parallel", "parallel-mcp", "none"):
533 args, extra = parser.parse_known_args(["--web-backend", value, "biosecurity"])
534 self.assertEqual(value, args.web_backend)
535 self.assertEqual([], extra)
536
537 def test_build_parser_rejects_invalid_web_backend(self):
538 parser = cli.build_parser()
539 with self.assertRaises(SystemExit):
540 parser.parse_known_args(["--web-backend", "bogus", "biosecurity"])
541
542 def test_build_parser_accepts_explicit_output_file(self):
543 parser = cli.build_parser()
544 args, extra = parser.parse_known_args(
545 ["--emit", "json", "--output", "results/run.json", "biosecurity"]
546 )
547 self.assertEqual("results/run.json", args.output)
548 self.assertEqual(["biosecurity"], args.topic)
549 self.assertEqual([], extra)
550
551 def test_build_parser_accepts_result_cap_overrides(self):
552 parser = cli.build_parser()
553 args, extra = parser.parse_known_args(
554 ["--max-results", "200", "--max-per-source", "60",
555 "--max-source-fetches", "8", "figma config 2026"]
556 )
557 self.assertEqual(200, args.max_results)
558 self.assertEqual(60, args.max_per_source)
559 self.assertEqual(8, args.max_source_fetches)
560 self.assertEqual(["figma config 2026"], args.topic)
561 self.assertEqual([], extra)
562
563 def test_result_cap_overrides_default_to_none(self):
564 parser = cli.build_parser()
565 args, _ = parser.parse_known_args(["figma config 2026"])
566 self.assertIsNone(args.max_results)
567 self.assertIsNone(args.max_per_source)
568 self.assertIsNone(args.max_source_fetches)
569
570 def test_research_unknown_flag_fails_before_config_load(self):
571 with mock.patch.object(
572 cli.env, "get_config", side_effect=AssertionError("config should not load")
573 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--save"]):
574 stderr = io.StringIO()
575 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
576 cli.main()
577 self.assertEqual(2, exc.exception.code)
578 self.assertIn("--save", stderr.getvalue())
579
580 def test_agent_is_skill_argument_not_python_cli_flag(self):
581 with mock.patch.object(
582 cli.env, "get_config", side_effect=AssertionError("config should not load")
583 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--agent"]):
584 stderr = io.StringIO()
585 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
586 cli.main()
587 self.assertEqual(2, exc.exception.code)
588 self.assertIn("skill arguments", stderr.getvalue())
589
590 def test_agent_error_includes_other_unknown_flags(self):
591 with mock.patch.object(
592 cli.env, "get_config", side_effect=AssertionError("config should not load")
593 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--agent", "--save"]):
594 stderr = io.StringIO()
595 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
596 cli.main()
597 self.assertEqual(2, exc.exception.code)
598 message = stderr.getvalue()
599 self.assertIn("--agent", message)
600 self.assertIn("--save", message)
601
602 def test_setup_passthrough_flags_remain_scoped_to_setup(self):
603 with mock.patch.object(cli.env, "get_config", return_value={}), \
604 mock.patch("lib.setup_wizard.run_github_auth", return_value={"status": "cancelled"}), \
605 mock.patch.object(sys, "argv", ["last30days.py", "setup", "--github"]):
606 stdout = io.StringIO()
607 stderr = io.StringIO()
608 with redirect_stdout(stdout), redirect_stderr(stderr):
609 rc = cli.main()
610 self.assertEqual(0, rc)
611
612 def test_setup_rejects_unknown_passthrough_flag_before_config_load(self):
613 with mock.patch.object(
614 cli.env, "get_config", side_effect=AssertionError("config should not load")
615 ), mock.patch.object(sys, "argv", ["last30days.py", "setup", "--bad"]):
616 stderr = io.StringIO()
617 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
618 cli.main()
619 self.assertEqual(2, exc.exception.code)
620 self.assertIn("--bad", stderr.getvalue())
621
622 def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
623 stderr = io.StringIO()
624 with redirect_stderr(stderr):
625 with self.assertRaises(SystemExit) as exc:
626 cli.ensure_supported_python((3, 9, 6))
627 self.assertEqual(1, exc.exception.code)
628 message = stderr.getvalue()
629 self.assertIn("last30days v3 requires Python 3.12+", message)
630 self.assertIn("Detected Python 3.9.6", message)
631 self.assertIn("python3.12", message)
632
633 def test_ensure_supported_python_allows_supported_interpreter(self):
634 cli.ensure_supported_python((3, 12, 0))
635
636 def test_missing_sources_for_promo_treats_x_as_optional(self):
637 self.assertEqual(
638 "reddit",
639 cli._missing_sources_for_promo({"available_sources": ["youtube"]}),
640 )
641 self.assertEqual(
642 "web",
643 cli._missing_sources_for_promo({"available_sources": ["reddit", "x"]}),
644 )
645 # The web promo is satisfied by a paid backend (better web search), not
646 # by the keyless grounding floor — keyless web is always available now.
647 self.assertIsNone(
648 cli._missing_sources_for_promo(
649 {"available_sources": ["reddit", "x", "grounding"], "native_web_backend": "brave"}
650 ),
651 )
652
653 def test_optional_x_omission_is_post_result_copy_for_default_runs(self):
654 note = cli._optional_x_omission_text(
655 {"available_sources": ["reddit", "youtube", "grounding"]},
656 None,
657 )
658 self.assertEqual(
659 "Optional source omitted: X/Twitter was not enabled; research "
660 "continued with the available sources.",
661 note,
662 )
663
664 def test_optional_x_omission_is_suppressed_when_x_active_or_search_explicit(self):
665 self.assertIsNone(
666 cli._optional_x_omission_text(
667 {"available_sources": ["reddit", "x", "youtube"]},
668 None,
669 )
670 )
671 self.assertIsNone(
672 cli._optional_x_omission_text(
673 {"available_sources": ["reddit", "youtube"]},
674 ["reddit", "youtube"],
675 )
676 )
677 # ...or suppressed entirely on a native-search host.
678 self.assertIsNone(
679 cli._missing_sources_for_promo(
680 {"available_sources": ["reddit", "x", "grounding"], "native_search": True}
681 ),
682 )
683
684 def test_slugify_and_emit_output_cover_supported_modes(self):
685 report = self.make_report()
686 self.assertEqual("openclaw-vs-nanoclaw", cli.slugify(report.topic))
687 self.assertEqual("last30days CLI.", cli.__doc__)
688
689 compact = cli.emit_output(report, "compact")
690 json_output = cli.emit_output(report, "json")
691 context = cli.emit_output(report, "context")
692 brief = cli.emit_output(report, "brief")
693
694 self.assertIn("# last30days v", compact)
695 self.assertIn('"query": "OpenClaw vs NanoClaw"', json_output)
696 self.assertIsInstance(context, str)
697 self.assertIn("# Production Brief:", brief)
698
699 with self.assertRaises(SystemExit):
700 cli.emit_output(report, "bad-mode")
701
702 def test_save_output_writes_expected_extension(self):
703 report = self.make_report()
704 with tempfile.TemporaryDirectory() as tmp:
705 path = cli.save_output(report, "json", tmp)
706 self.assertEqual(".json", path.suffix)
707 payload = json.loads(path.read_text())
708 self.assertEqual("OpenClaw vs NanoClaw", payload["query"])
709
710 def test_compact_emit_saves_full_artifact_not_compact_render(self):
711 """A --emit=compact --save-dir run must save the complete debug
712 artifact (all clusters plus per-source items), not the compact stdout
713 render. Saving the compact render made most collected evidence
714 unrecoverable from the raw file (#923)."""
715 with tempfile.TemporaryDirectory() as tmp:
716 result = subprocess.run(
717 [
718 sys.executable,
719 "skills/last30days/scripts/last30days.py",
720 "compact save probe",
721 "--mock",
722 "--emit=compact",
723 f"--save-dir={tmp}",
724 ],
725 cwd=REPO_ROOT,
726 capture_output=True,
727 text=True,
728 encoding="utf-8",
729 check=False,
730 )
731 self.assertEqual(0, result.returncode, result.stderr)
732 saved = list(Path(tmp).glob("*.md"))
733 self.assertEqual(1, len(saved), saved)
734 content = saved[0].read_text(encoding="utf-8")
735 self.assertIn("## All Items by Source", content)
736 self.assertNotIn("## All Items by Source", result.stdout)
737
738 def test_save_output_uses_unique_dated_fallback(self):
739 report = self.make_report()
740 with tempfile.TemporaryDirectory() as tmp:
741 save_dir = Path(tmp)
742 today = datetime.now().strftime("%Y-%m-%d")
743 base = save_dir / "openclaw-vs-nanoclaw-raw.md"
744 dated = save_dir / f"openclaw-vs-nanoclaw-raw-{today}.md"
745 base.write_text("base content", encoding="utf-8")
746 dated.write_text("dated content", encoding="utf-8")
747
748 saved = cli.save_output(report, "md", tmp)
749
750 self.assertEqual((save_dir / f"openclaw-vs-nanoclaw-raw-{today}-1.md").resolve(), saved)
751 self.assertEqual("base content", base.read_text(encoding="utf-8"))
752 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
753 self.assertTrue(saved.exists())
754
755 def test_save_output_render_fn_footer_names_actual_collision_path(self):
756 from lib import render as render_module
757
758 report = self.make_report()
759 with tempfile.TemporaryDirectory() as tmp:
760 save_dir = Path(tmp)
761 today = datetime.now().strftime("%Y-%m-%d")
762 base = save_dir / "openclaw-vs-nanoclaw-raw.md"
763 dated = save_dir / f"openclaw-vs-nanoclaw-raw-{today}.md"
764 base.write_text("base content", encoding="utf-8")
765 dated.write_text("dated content", encoding="utf-8")
766
767 def render_fn(actual_path: Path) -> str:
768 return render_module.render_compact(report, save_path=str(actual_path))
769
770 saved = cli.save_output(report, "md", tmp, render_fn=render_fn)
771
772 expected = (save_dir / f"openclaw-vs-nanoclaw-raw-{today}-1.md").resolve()
773 self.assertEqual(expected, saved.resolve())
774 content = saved.read_text(encoding="utf-8")
775 self.assertIn(f"Raw results saved to {saved}", content)
776 self.assertNotIn(f"Raw results saved to {base}", content)
777 self.assertNotIn(f"Raw results saved to {dated}", content)
778 self.assertEqual("base content", base.read_text(encoding="utf-8"))
779 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
780
781 def test_save_output_removes_reserved_candidate_when_deferred_render_fails(self):
782 report = self.make_report()
783 with tempfile.TemporaryDirectory() as tmp:
784 save_dir = Path(tmp)
785
786 def fail_render(_actual_path: Path) -> str:
787 raise RuntimeError("render failed")
788
789 with self.assertRaisesRegex(RuntimeError, "render failed"):
790 cli.save_output(report, "md", tmp, render_fn=fail_render)
791
792 self.assertEqual([], list(save_dir.iterdir()))
793
794 def test_render_save_and_print_uses_actual_collision_path_in_file_and_stdout(self):
795 report = self.make_report(topic="Collision Topic")
796 with tempfile.TemporaryDirectory() as tmp:
797 save_dir = Path(tmp)
798 today = datetime.now().strftime("%Y-%m-%d")
799 base = save_dir / "collision-topic-raw.md"
800 dated = save_dir / f"collision-topic-raw-{today}.md"
801 expected = save_dir / f"collision-topic-raw-{today}-1.md"
802 base.write_text("base content", encoding="utf-8")
803 dated.write_text("dated content", encoding="utf-8")
804 args = types.SimpleNamespace(
805 topic=["Collision Topic"],
806 competitors=None,
807 competitors_list=None,
808 competitors_plan=None,
809 drill=False,
810 register=None,
811 emit="compact",
812 output=None,
813 save_dir=str(save_dir),
814 save_suffix="",
815 json_profile="agent",
816 publish_html=False,
817 )
818 stdout = io.StringIO()
819 stderr = io.StringIO()
820
821 with redirect_stdout(stdout), redirect_stderr(stderr):
822 rc = cli._render_save_and_print(args, report, None, None, {})
823
824 self.assertEqual(0, rc)
825 expected_display = cli.compute_output_path_display(str(expected))
826 footer = f"Raw results saved to {expected_display}"
827 self.assertTrue(expected.exists())
828 self.assertIn(footer, expected.read_text(encoding="utf-8"))
829 self.assertIn(footer, stdout.getvalue())
830 self.assertEqual("base content", base.read_text(encoding="utf-8"))
831 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
832
833 def test_save_output_writes_utf8_encoded_markdown(self):
834 report = self.make_report()
835 with tempfile.TemporaryDirectory() as tmp:
836 path = cli.save_output(report, "md", tmp)
837 raw = path.read_bytes()
838 content = path.read_text(encoding="utf-8")
839 self.assertIn(report.topic, content)
840 # Verify the raw bytes decode cleanly as UTF-8.
841 self.assertEqual(content, raw.decode("utf-8"))
842
843 def test_save_rendered_output_writes_exact_file_path(self):
844 with tempfile.TemporaryDirectory() as tmp:
845 out_path = Path(tmp) / "nested" / "results.json"
846 saved = cli.save_rendered_output('{"ok": true}', str(out_path))
847 self.assertEqual(out_path.resolve(), saved)
848 self.assertEqual('{"ok": true}', out_path.read_text(encoding="utf-8"))
849
850 def test_compute_save_path_display_uses_posix_slashes_under_home(self):
851 # Regression: f"~/{relative}" stringified pathlib.Path with the
852 # OS-native separator, producing "~/Documents\\Last30Days\\..." on
853 # Windows that no shell or File Explorer could open. The fix is
854 # f"~/{relative.as_posix()}" which forces forward slashes regardless
855 # of host OS. On POSIX hosts this asserts the contract for
856 # cross-platform safety; on Windows hosts it would fail without the fix.
857 real_home = Path.home()
858 tmp_under_home = Path(tempfile.mkdtemp(prefix="l30d_save_path_", dir=str(real_home)))
859 try:
860 save_dir = tmp_under_home / "Documents" / "Last30Days"
861 save_dir.mkdir(parents=True, exist_ok=True)
862 display = cli.compute_save_path_display(
863 str(save_dir), "british airways middle east", "v3", "compact"
864 )
865 self.assertTrue(display.startswith("~/"), f"Expected '~/' prefix, got: {display}")
866 self.assertNotIn("\\", display, f"Backslash leaked into display: {display}")
867 self.assertTrue(
868 display.endswith("british-airways-middle-east-raw-v3.md"),
869 f"Expected slug+suffix at end, got: {display}",
870 )
871 finally:
872 shutil.rmtree(tmp_under_home, ignore_errors=True)
873
874 def test_compute_output_path_display_uses_posix_slashes_under_home(self):
875 real_home = Path.home()
876 tmp_under_home = Path(tempfile.mkdtemp(prefix="l30d_output_path_", dir=str(real_home)))
877 try:
878 output_path = tmp_under_home / "Documents" / "Last30Days" / "run.json"
879 display = cli.compute_output_path_display(str(output_path))
880 self.assertTrue(display.startswith("~/"), f"Expected '~/' prefix, got: {display}")
881 self.assertNotIn("\\", display, f"Backslash leaked into display: {display}")
882 self.assertTrue(display.endswith("Documents/Last30Days/run.json"), display)
883 finally:
884 shutil.rmtree(tmp_under_home, ignore_errors=True)
885
886 def test_persist_report_updates_run_status_on_success_and_failure(self):
887 report = self.make_report()
888
889 success_store = types.SimpleNamespace(
890 scoped_db=lambda _path: contextlib.nullcontext(),
891 init_db=mock.Mock(),
892 add_topic=mock.Mock(return_value={"id": 7}),
893 record_run=mock.Mock(return_value=11),
894 findings_from_report=mock.Mock(return_value=[{"title": "x"}]),
895 store_findings=mock.Mock(return_value={"new": 2, "updated": 1}),
896 update_run=mock.Mock(),
897 )
898 with mock.patch.dict(sys.modules, {"store": success_store}):
899 counts = cli.persist_report(report)
900 self.assertEqual({"new": 2, "updated": 1}, counts)
901 success_store.update_run.assert_called_once_with(
902 11,
903 status="completed",
904 findings_new=2,
905 findings_updated=1,
906 )
907
908 failure_store = types.SimpleNamespace(
909 scoped_db=lambda _path: contextlib.nullcontext(),
910 init_db=mock.Mock(),
911 add_topic=mock.Mock(return_value={"id": 7}),
912 record_run=mock.Mock(return_value=12),
913 findings_from_report=mock.Mock(side_effect=RuntimeError("boom")),
914 store_findings=mock.Mock(),
915 update_run=mock.Mock(),
916 )
917 with mock.patch.dict(sys.modules, {"store": failure_store}):
918 with self.assertRaises(RuntimeError):
919 cli.persist_report(report)
920 failure_store.update_run.assert_called_once()
921 _, kwargs = failure_store.update_run.call_args
922 self.assertEqual("failed", kwargs["status"])
923 self.assertIn("boom", kwargs["error_message"])
924
925 def test_main_wires_banner_and_progress_display(self):
926 report = self.make_report()
927 diag = {
928 "available_sources": ["grounding", "youtube"],
929 "providers": {"google": True, "openai": False, "xai": False},
930 "x_backend": None,
931 "bird_installed": True,
932 "bird_authenticated": False,
933 "bird_username": None,
934 "native_web_backend": "brave",
935 }
936 fake_progress = mock.Mock()
937 with mock.patch.object(cli.env, "get_config", return_value={}), \
938 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
939 mock.patch.object(cli.pipeline, "run", return_value=report), \
940 mock.patch.object(cli.ui, "show_diagnostic_banner") as banner, \
941 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress) as progress_cls, \
942 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
943 mock.patch.object(sys, "argv", ["last30days.py", "test", "topic"]):
944 stdout = io.StringIO()
945 stderr = io.StringIO()
946 with redirect_stdout(stdout), redirect_stderr(stderr):
947 rc = cli.main()
948 self.assertEqual(0, rc)
949 banner.assert_not_called() # Banner moved to post-research
950 progress_cls.assert_called_once_with("test topic", show_banner=True)
951 fake_progress.start_processing.assert_called_once()
952 fake_progress.end_processing.assert_called_once()
953 fake_progress.show_complete.assert_called_once_with(
954 source_counts={"grounding": 0},
955 display_sources=["grounding"],
956 )
957 fake_progress.show_promo.assert_called_once_with("reddit", diag=diag)
958 self.assertIn("# rendered", stdout.getvalue())
959
960 def test_main_writes_rendered_output_to_explicit_file(self):
961 report = self.make_report()
962 diag = {
963 "available_sources": ["grounding"],
964 "providers": {"google": True, "openai": False, "xai": False},
965 "x_backend": None,
966 "bird_installed": True,
967 "bird_authenticated": False,
968 "bird_username": None,
969 "native_web_backend": "brave",
970 }
971 with tempfile.TemporaryDirectory() as tmp:
972 output_path = Path(tmp) / "exports" / "run.json"
973 with mock.patch.object(cli.env, "get_config", return_value={}), \
974 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
975 mock.patch.object(cli.pipeline, "run", return_value=report), \
976 mock.patch.object(cli, "emit_output", return_value='{"rendered": true}') as emit, \
977 mock.patch.object(sys, "argv", [
978 "last30days.py",
979 "test",
980 "topic",
981 "--emit=json",
982 "--output",
983 str(output_path),
984 ]):
985 stdout = io.StringIO()
986 stderr = io.StringIO()
987 with redirect_stdout(stdout), redirect_stderr(stderr):
988 rc = cli.main()
989 self.assertEqual(0, rc)
990 emit.assert_called_once()
991 self.assertEqual('{"rendered": true}\n', stdout.getvalue())
992 self.assertEqual('{"rendered": true}', output_path.read_text(encoding="utf-8"))
993 self.assertIn(f"[last30days] Saved output to {output_path.resolve()}", stderr.getvalue())
994
995 def test_main_combines_output_and_save_dir_for_comparison_html(self):
996 diag = {
997 "available_sources": ["grounding"],
998 "providers": {"google": True, "openai": False, "xai": False},
999 "x_backend": None,
1000 "bird_installed": True,
1001 "bird_authenticated": False,
1002 "bird_username": None,
1003 "native_web_backend": "brave",
1004 }
1005 fake_progress = mock.Mock()
1006
1007 def run_report(*_args, **kwargs):
1008 return self.make_report(topic=kwargs["topic"])
1009
1010 with tempfile.TemporaryDirectory() as tmp:
1011 output_path = Path(tmp) / "exports" / "comparison.html"
1012 save_dir = Path(tmp) / "saved"
1013 with mock.patch.object(cli.env, "get_config", return_value={}), \
1014 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
1015 mock.patch.object(cli.pipeline, "run", side_effect=run_report), \
1016 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
1017 mock.patch.object(
1018 cli, "emit_comparison_output", return_value="<html>comparison</html>"
1019 ) as emit_comparison, \
1020 mock.patch.object(cli, "emit_output", return_value="<html>peer</html>"), \
1021 mock.patch.object(sys, "argv", [
1022 "last30days.py",
1023 "Alpha",
1024 "vs",
1025 "Beta",
1026 "--mock",
1027 "--emit=html",
1028 "--output",
1029 str(output_path),
1030 "--save-dir",
1031 str(save_dir),
1032 ]):
1033 stdout = io.StringIO()
1034 stderr = io.StringIO()
1035 with redirect_stdout(stdout), redirect_stderr(stderr):
1036 rc = cli.main()
1037
1038 self.assertEqual(0, rc)
1039 output_display = cli.compute_output_path_display(str(output_path))
1040 comparison_saved = save_dir / "alpha-vs-beta-raw-html.html"
1041 self.assertEqual(2, emit_comparison.call_count)
1042 first_kwargs = emit_comparison.call_args_list[0].kwargs
1043 second_kwargs = emit_comparison.call_args_list[1].kwargs
1044 self.assertEqual(output_display, first_kwargs["save_path"])
1045 comparison_display = cli.compute_output_path_display(str(comparison_saved))
1046 self.assertEqual(comparison_display, second_kwargs["save_path"])
1047 self.assertEqual("<html>comparison</html>\n", stdout.getvalue())
1048 self.assertEqual("<html>comparison</html>", output_path.read_text(encoding="utf-8"))
1049 self.assertEqual(
1050 "<html>comparison</html>",
1051 comparison_saved.read_text(encoding="utf-8"),
1052 )
1053 peer_saved = save_dir / "beta-raw-html.html"
1054 self.assertEqual("<html>peer</html>", peer_saved.read_text(encoding="utf-8"))
1055 self.assertIn(f"[last30days] Saved output to {output_path.resolve()}", stderr.getvalue())
1056 self.assertIn(f"[last30days] Saved output to {comparison_saved.resolve()}", stderr.getvalue())
1057 self.assertIn(f"[last30days] Saved output to {peer_saved.resolve()}", stderr.getvalue())
1058 self.assertIn(
1059 f"[last30days] Comparison artifact set: main={comparison_saved.resolve()}; "
1060 f"peers={peer_saved.resolve()}",
1061 stderr.getvalue(),
1062 )
1063
1064 def test_main_canonicalizes_explicit_github_repo_flags(self):
1065 report = self.make_report()
1066 diag = {
1067 "available_sources": ["grounding"],
1068 "providers": {"google": True, "openai": False, "xai": False},
1069 "x_backend": None,
1070 "bird_installed": True,
1071 "bird_authenticated": False,
1072 "bird_username": None,
1073 "native_web_backend": "brave",
1074 }
1075 with mock.patch.object(cli.env, "get_config", return_value={}), \
1076 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
1077 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
1078 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
1079 mock.patch.object(sys, "argv", [
1080 "last30days.py",
1081 "claude",
1082 "code",
1083 "vs",
1084 "codex",
1085 "--github-repo",
1086 "openai/codex,anthropics/claude-code-action",
1087 ]):
1088 stdout = io.StringIO()
1089 stderr = io.StringIO()
1090 with redirect_stdout(stdout), redirect_stderr(stderr):
1091 rc = cli.main()
1092 self.assertEqual(0, rc)
1093 # In vs-mode main + competitors run in parallel via ThreadPoolExecutor,
1094 # so the order of pipeline.run invocations is non-deterministic. Find
1095 # the main runner's call by predicate on the canonicalized github_repos
1096 # rather than by index.
1097 expected_repos = ["openai/codex", "anthropics/claude-code"]
1098 main_call = next(
1099 (c for c in run_mock.call_args_list if c.kwargs.get("github_repos") == expected_repos),
1100 None,
1101 )
1102 self.assertIsNotNone(
1103 main_call,
1104 f"No pipeline.run call had github_repos={expected_repos}; "
1105 f"saw {[c.kwargs.get('github_repos') for c in run_mock.call_args_list]}",
1106 )
1107 self.assertIn("[GitHub] Canonicalized repos:", stderr.getvalue())
1108
1109 def test_main_passes_trustpilot_domain_to_pipeline_run(self):
1110 """The user-set flag must reach pipeline.run verbatim with
1111 provenance user-set (is_hint False) on the single-topic path."""
1112 report = self.make_report()
1113 diag = {
1114 "available_sources": ["grounding"],
1115 "providers": {"google": True, "openai": False, "xai": False},
1116 "x_backend": None,
1117 "bird_installed": True,
1118 "bird_authenticated": False,
1119 "bird_username": None,
1120 "native_web_backend": "brave",
1121 }
1122 with mock.patch.object(cli.env, "get_config", return_value={}), \
1123 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
1124 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
1125 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
1126 mock.patch.object(sys, "argv", [
1127 "last30days.py",
1128 "ThriftBooks",
1129 "--trustpilot-domain",
1130 "www.thriftbooks.com",
1131 ]):
1132 stdout = io.StringIO()
1133 stderr = io.StringIO()
1134 with redirect_stdout(stdout), redirect_stderr(stderr):
1135 rc = cli.main()
1136 self.assertEqual(0, rc)
1137 main_call = next(
1138 (c for c in run_mock.call_args_list
1139 if c.kwargs.get("trustpilot_domain") == "www.thriftbooks.com"),
1140 None,
1141 )
1142 self.assertIsNotNone(
1143 main_call,
1144 f"No pipeline.run call carried trustpilot_domain; saw "
1145 f"{[c.kwargs.get('trustpilot_domain') for c in run_mock.call_args_list]}",
1146 )
1147 self.assertFalse(main_call.kwargs.get("trustpilot_domain_is_hint"))
1148
1149 def test_trustpilot_domain_auto_activates_include_sources(self):
1150 """Explicit --trustpilot-domain must activate Trustpilot even when
1151 INCLUDE_SOURCES omits it (#873) — otherwise the flag silently no-ops."""
1152 report = self.make_report(topic="Weber grills")
1153 diag = {
1154 "available_sources": ["tiktok", "instagram"],
1155 "providers": {"google": True, "openai": False, "xai": False},
1156 "x_backend": None,
1157 "bird_installed": True,
1158 "bird_authenticated": False,
1159 "bird_username": None,
1160 "native_web_backend": "brave",
1161 }
1162 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
1163 with mock.patch.object(cli.env, "get_config", return_value=config), \
1164 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
1165 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
1166 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
1167 mock.patch.object(sys, "argv", [
1168 "last30days.py",
1169 "Weber grills",
1170 "--trustpilot-domain",
1171 "weber.co.uk",
1172 ]):
1173 stdout = io.StringIO()
1174 stderr = io.StringIO()
1175 with redirect_stdout(stdout), redirect_stderr(stderr):
1176 rc = cli.main()
1177 self.assertEqual(0, rc)
1178 self.assertIn("trustpilot", config["INCLUDE_SOURCES"].lower())
1179 self.assertIn("[Trustpilot] --trustpilot-domain=weber.co.uk activated", stderr.getvalue())
1180 main_call = run_mock.call_args_list[0]
1181 self.assertEqual(main_call.kwargs.get("trustpilot_domain"), "weber.co.uk")
1182
1183 def test_trustpilot_domain_auto_activates_with_search_filter(self):
1184 """When --search omits trustpilot, the explicit domain flag must still
1185 append it to requested_sources so the intersection filter cannot drop it."""
1186 report = self.make_report(topic="Weber grills")
1187 diag = {
1188 "available_sources": ["tiktok", "instagram", "trustpilot"],
1189 "providers": {"google": True, "openai": False, "xai": False},
1190 "x_backend": None,
1191 "bird_installed": True,
1192 "bird_authenticated": False,
1193 "bird_username": None,
1194 "native_web_backend": "brave",
1195 }
1196 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
1197 with mock.patch.object(cli.env, "get_config", return_value=config), \
1198 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
1199 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
1200 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
1201 mock.patch.object(sys, "argv", [
1202 "last30days.py",
1203 "Weber grills",
1204 "--search",
1205 "tiktok,instagram",
1206 "--trustpilot-domain",
1207 "weber.co.uk",
1208 ]):
1209 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
1210 rc = cli.main()
1211 self.assertEqual(0, rc)
1212 requested = run_mock.call_args_list[0].kwargs.get("requested_sources") or []
1213 self.assertIn("trustpilot", requested)
1214
1215 def test_trustpilot_domain_respects_exclude_sources(self):
1216 config = {"INCLUDE_SOURCES": "tiktok", "EXCLUDE_SOURCES": "trustpilot"}
1217 requested = cli.activate_trustpilot_for_explicit_domain(
1218 config, ["tiktok"], reason="--trustpilot-domain=weber.co.uk",
1219 )
1220 self.assertEqual(requested, ["tiktok"])
1221 self.assertNotIn("trustpilot", config["INCLUDE_SOURCES"].lower())
1222
1223
1224 class ActivateTrustpilotHelperTests(unittest.TestCase):
1225 def test_plan_has_explicit_trustpilot_domain(self):
1226 self.assertTrue(cli.plan_has_explicit_trustpilot_domain({
1227 "traeger": {"trustpilot_domain": "traeger.com"},
1228 }))
1229 self.assertFalse(cli.plan_has_explicit_trustpilot_domain({
1230 "traeger": {"x_handle": "Traeger"},
1231 }))
1232 self.assertFalse(cli.plan_has_explicit_trustpilot_domain(None))
1233
1234 def test_activate_adds_include_and_requested(self):
1235 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
1236 requested = cli.activate_trustpilot_for_explicit_domain(
1237 config, ["tiktok", "instagram"], reason="--trustpilot-domain=x.com",
1238 )
1239 self.assertIn("trustpilot", config["INCLUDE_SOURCES"].lower())
1240 self.assertEqual(requested, ["tiktok", "instagram", "trustpilot"])
1241
1242 def test_activate_noop_when_already_present(self):
1243 config = {"INCLUDE_SOURCES": "tiktok,trustpilot"}
1244 requested = cli.activate_trustpilot_for_explicit_domain(
1245 config, ["trustpilot"], reason="--trustpilot-domain=x.com",
1246 )
1247 self.assertEqual(config["INCLUDE_SOURCES"], "tiktok,trustpilot")
1248 self.assertEqual(requested, ["trustpilot"])
1249
1250
1251 if __name__ == "__main__":
1252 unittest.main()
1253
1253 lines PYTHON