返回 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 shutil
6 import tempfile
7 import subprocess
8 import sys
9 import types
10 import unittest
11 from contextlib import redirect_stderr, redirect_stdout
12 from datetime import datetime
13 from pathlib import Path
14 from unittest import mock
15
16 import last30days as cli
17 from lib import schema
18
19 REPO_ROOT = Path(__file__).resolve().parents[1]
20
21
22 class CliV3Tests(unittest.TestCase):
23 def make_report(self, topic: str = "OpenClaw vs NanoClaw") -> schema.Report:
24 return schema.Report(
25 topic=topic,
26 range_from="2026-02-14",
27 range_to="2026-03-16",
28 generated_at="2026-03-16T00:00:00+00:00",
29 provider_runtime=schema.ProviderRuntime(
30 reasoning_provider="gemini",
31 planner_model="gemini-3.1-flash-lite",
32 rerank_model="gemini-3.1-flash-lite",
33 ),
34 query_plan=schema.QueryPlan(
35 intent="comparison",
36 freshness_mode="balanced_recent",
37 cluster_mode="debate",
38 raw_topic=topic,
39 subqueries=[
40 schema.SubQuery(
41 label="primary",
42 search_query=topic.lower(),
43 ranking_query=f"What are people saying about {topic}?",
44 sources=["grounding"],
45 )
46 ],
47 source_weights={"grounding": 1.0},
48 ),
49 clusters=[],
50 ranked_candidates=[],
51 items_by_source={"grounding": []},
52 errors_by_source={},
53 )
54
55 def test_mock_json_cli(self):
56 result = subprocess.run(
57 [sys.executable, "skills/last30days/scripts/last30days.py", "test topic", "--mock", "--emit=json"],
58 cwd=REPO_ROOT,
59 capture_output=True,
60 text=True,
61 encoding="utf-8",
62 check=False,
63 )
64 self.assertEqual(0, result.returncode, result.stderr)
65 payload = json.loads(result.stdout)
66 self.assertEqual("1.2", payload["schema_version"])
67 self.assertEqual("test topic", payload["query"])
68 self.assertIn("results", payload)
69 self.assertIn("clusters", payload)
70 self.assertIn("source_status", payload)
71
72 def test_invalid_plan_json_exits_nonzero(self):
73 """Malformed --plan JSON must fail fast, not silently fall back to the
74 internal planner and burn a paid run the user did not ask for."""
75 result = subprocess.run(
76 [
77 sys.executable,
78 "skills/last30days/scripts/last30days.py",
79 "test topic",
80 "--mock",
81 "--emit=json",
82 "--plan",
83 "{not valid json",
84 ],
85 cwd=REPO_ROOT,
86 capture_output=True,
87 text=True,
88 encoding="utf-8",
89 check=False,
90 )
91 self.assertEqual(2, result.returncode, result.stderr)
92 self.assertIn("Invalid --plan JSON", result.stderr)
93
94 def test_invalid_plan_structure_exits_nonzero_without_fallback(self):
95 result = subprocess.run(
96 [
97 sys.executable,
98 "skills/last30days/scripts/last30days.py",
99 "test topic",
100 "--mock",
101 "--emit=json",
102 "--plan",
103 json.dumps({"queries": {"web": ["Berlin"]}}),
104 ],
105 cwd=REPO_ROOT,
106 capture_output=True,
107 text=True,
108 encoding="utf-8",
109 check=False,
110 )
111 self.assertEqual(2, result.returncode, result.stderr)
112 self.assertIn("Invalid --plan schema", result.stderr)
113 self.assertNotIn("fallback-plan", result.stderr)
114
115 def test_parse_search_flag_normalizes_aliases_and_dedupes(self):
116 self.assertEqual(
117 ["grounding", "reddit", "hackernews"],
118 cli.parse_search_flag("web, reddit, hn, web"),
119 )
120
121 def test_parse_search_flag_accepts_optional_social_sources(self):
122 self.assertEqual(
123 ["threads", "pinterest"],
124 cli.parse_search_flag("threads, pinterest"),
125 )
126
127 def test_explicit_threads_search_uses_scrapecreators_key_without_include_sources(self):
128 available = cli.pipeline.available_sources(
129 {"SCRAPECREATORS_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
130 requested_sources=["threads"],
131 )
132 self.assertIn("threads", available)
133
134 def test_explicit_perplexity_search_uses_openrouter_key_without_include_sources(self):
135 available = cli.pipeline.available_sources(
136 {"OPENROUTER_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
137 requested_sources=["perplexity"],
138 )
139 self.assertIn("perplexity", available)
140
141 def test_explicit_perplexity_search_uses_direct_key_without_include_sources(self):
142 available = cli.pipeline.available_sources(
143 {"PERPLEXITY_API_KEY": "test-key", "INCLUDE_SOURCES": ""},
144 requested_sources=["perplexity"],
145 )
146 self.assertIn("perplexity", available)
147
148 def test_parse_search_flag_rejects_invalid_or_empty_inputs(self):
149 with self.assertRaises(SystemExit):
150 cli.parse_search_flag("unknown")
151 with self.assertRaises(SystemExit):
152 cli.parse_search_flag(" , ")
153
154 def test_resolve_requested_sources_flag_wins_over_config_default(self):
155 sources = cli.resolve_requested_sources(
156 "reddit", {"LAST30DAYS_DEFAULT_SEARCH": "x,youtube"},
157 )
158 self.assertEqual(["reddit"], sources)
159
160 def test_resolve_requested_sources_falls_back_to_config_default(self):
161 sources = cli.resolve_requested_sources(
162 None, {"LAST30DAYS_DEFAULT_SEARCH": "web, reddit, hn"},
163 )
164 self.assertEqual(["grounding", "reddit", "hackernews"], sources)
165
166 def test_resolve_requested_sources_none_when_neither_set(self):
167 self.assertIsNone(cli.resolve_requested_sources(None, {}))
168 self.assertIsNone(
169 cli.resolve_requested_sources(None, {"LAST30DAYS_DEFAULT_SEARCH": ""})
170 )
171 self.assertIsNone(
172 cli.resolve_requested_sources(None, {"LAST30DAYS_DEFAULT_SEARCH": " "})
173 )
174
175 def test_resolve_requested_sources_invalid_config_default_names_env_var(self):
176 with self.assertRaises(SystemExit) as exc:
177 cli.resolve_requested_sources(
178 None, {"LAST30DAYS_DEFAULT_SEARCH": "notasource"},
179 )
180 self.assertIn("LAST30DAYS_DEFAULT_SEARCH", str(exc.exception))
181
182 def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
183 parser = cli.build_parser()
184 args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
185 self.assertEqual(7, args.lookback_days)
186 self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
187 self.assertEqual([], extra)
188
189 def test_build_parser_accepts_web_backend_keyless(self):
190 """Regression for #905: CONFIGURATION.md documents --web-backend=keyless
191 to force the zero-key floor, but the choices list rejected it."""
192 parser = cli.build_parser()
193 args, extra = parser.parse_known_args(["--web-backend", "keyless", "biosecurity"])
194 self.assertEqual("keyless", args.web_backend)
195 self.assertEqual([], extra)
196
197 def test_build_parser_still_accepts_other_web_backend_values(self):
198 parser = cli.build_parser()
199 for value in ("auto", "brave", "exa", "serper", "parallel", "none"):
200 args, extra = parser.parse_known_args(["--web-backend", value, "biosecurity"])
201 self.assertEqual(value, args.web_backend)
202 self.assertEqual([], extra)
203
204 def test_build_parser_rejects_invalid_web_backend(self):
205 parser = cli.build_parser()
206 with self.assertRaises(SystemExit):
207 parser.parse_known_args(["--web-backend", "bogus", "biosecurity"])
208
209 def test_build_parser_accepts_explicit_output_file(self):
210 parser = cli.build_parser()
211 args, extra = parser.parse_known_args(
212 ["--emit", "json", "--output", "results/run.json", "biosecurity"]
213 )
214 self.assertEqual("results/run.json", args.output)
215 self.assertEqual(["biosecurity"], args.topic)
216 self.assertEqual([], extra)
217
218 def test_build_parser_accepts_result_cap_overrides(self):
219 parser = cli.build_parser()
220 args, extra = parser.parse_known_args(
221 ["--max-results", "200", "--max-per-source", "60",
222 "--max-source-fetches", "8", "figma config 2026"]
223 )
224 self.assertEqual(200, args.max_results)
225 self.assertEqual(60, args.max_per_source)
226 self.assertEqual(8, args.max_source_fetches)
227 self.assertEqual(["figma config 2026"], args.topic)
228 self.assertEqual([], extra)
229
230 def test_result_cap_overrides_default_to_none(self):
231 parser = cli.build_parser()
232 args, _ = parser.parse_known_args(["figma config 2026"])
233 self.assertIsNone(args.max_results)
234 self.assertIsNone(args.max_per_source)
235 self.assertIsNone(args.max_source_fetches)
236
237 def test_research_unknown_flag_fails_before_config_load(self):
238 with mock.patch.object(
239 cli.env, "get_config", side_effect=AssertionError("config should not load")
240 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--save"]):
241 stderr = io.StringIO()
242 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
243 cli.main()
244 self.assertEqual(2, exc.exception.code)
245 self.assertIn("--save", stderr.getvalue())
246
247 def test_agent_is_skill_argument_not_python_cli_flag(self):
248 with mock.patch.object(
249 cli.env, "get_config", side_effect=AssertionError("config should not load")
250 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--agent"]):
251 stderr = io.StringIO()
252 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
253 cli.main()
254 self.assertEqual(2, exc.exception.code)
255 self.assertIn("skill arguments", stderr.getvalue())
256
257 def test_agent_error_includes_other_unknown_flags(self):
258 with mock.patch.object(
259 cli.env, "get_config", side_effect=AssertionError("config should not load")
260 ), mock.patch.object(sys, "argv", ["last30days.py", "topic", "--agent", "--save"]):
261 stderr = io.StringIO()
262 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
263 cli.main()
264 self.assertEqual(2, exc.exception.code)
265 message = stderr.getvalue()
266 self.assertIn("--agent", message)
267 self.assertIn("--save", message)
268
269 def test_setup_passthrough_flags_remain_scoped_to_setup(self):
270 with mock.patch.object(cli.env, "get_config", return_value={}), \
271 mock.patch("lib.setup_wizard.run_github_auth", return_value={"status": "cancelled"}), \
272 mock.patch.object(sys, "argv", ["last30days.py", "setup", "--github"]):
273 stdout = io.StringIO()
274 stderr = io.StringIO()
275 with redirect_stdout(stdout), redirect_stderr(stderr):
276 rc = cli.main()
277 self.assertEqual(0, rc)
278
279 def test_setup_rejects_unknown_passthrough_flag_before_config_load(self):
280 with mock.patch.object(
281 cli.env, "get_config", side_effect=AssertionError("config should not load")
282 ), mock.patch.object(sys, "argv", ["last30days.py", "setup", "--bad"]):
283 stderr = io.StringIO()
284 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
285 cli.main()
286 self.assertEqual(2, exc.exception.code)
287 self.assertIn("--bad", stderr.getvalue())
288
289 def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
290 stderr = io.StringIO()
291 with redirect_stderr(stderr):
292 with self.assertRaises(SystemExit) as exc:
293 cli.ensure_supported_python((3, 9, 6))
294 self.assertEqual(1, exc.exception.code)
295 message = stderr.getvalue()
296 self.assertIn("last30days v3 requires Python 3.12+", message)
297 self.assertIn("Detected Python 3.9.6", message)
298 self.assertIn("python3.12", message)
299
300 def test_ensure_supported_python_allows_supported_interpreter(self):
301 cli.ensure_supported_python((3, 12, 0))
302
303 def test_missing_sources_for_promo_prefers_reddit_x_then_web(self):
304 self.assertEqual(
305 "both",
306 cli._missing_sources_for_promo({"available_sources": ["youtube"]}),
307 )
308 self.assertEqual(
309 "web",
310 cli._missing_sources_for_promo({"available_sources": ["reddit", "x"]}),
311 )
312 # The web promo is satisfied by a paid backend (better web search), not
313 # by the keyless grounding floor — keyless web is always available now.
314 self.assertIsNone(
315 cli._missing_sources_for_promo(
316 {"available_sources": ["reddit", "x", "grounding"], "native_web_backend": "brave"}
317 ),
318 )
319 # ...or suppressed entirely on a native-search host.
320 self.assertIsNone(
321 cli._missing_sources_for_promo(
322 {"available_sources": ["reddit", "x", "grounding"], "native_search": True}
323 ),
324 )
325
326 def test_slugify_and_emit_output_cover_supported_modes(self):
327 report = self.make_report()
328 self.assertEqual("openclaw-vs-nanoclaw", cli.slugify(report.topic))
329 self.assertEqual("last30days CLI.", cli.__doc__)
330
331 compact = cli.emit_output(report, "compact")
332 json_output = cli.emit_output(report, "json")
333 context = cli.emit_output(report, "context")
334 brief = cli.emit_output(report, "brief")
335
336 self.assertIn("# last30days v", compact)
337 self.assertIn('"query": "OpenClaw vs NanoClaw"', json_output)
338 self.assertIsInstance(context, str)
339 self.assertIn("# Production Brief:", brief)
340
341 with self.assertRaises(SystemExit):
342 cli.emit_output(report, "bad-mode")
343
344 def test_save_output_writes_expected_extension(self):
345 report = self.make_report()
346 with tempfile.TemporaryDirectory() as tmp:
347 path = cli.save_output(report, "json", tmp)
348 self.assertEqual(".json", path.suffix)
349 payload = json.loads(path.read_text())
350 self.assertEqual("OpenClaw vs NanoClaw", payload["query"])
351
352 def test_save_output_uses_unique_dated_fallback(self):
353 report = self.make_report()
354 with tempfile.TemporaryDirectory() as tmp:
355 save_dir = Path(tmp)
356 today = datetime.now().strftime("%Y-%m-%d")
357 base = save_dir / "openclaw-vs-nanoclaw-raw.md"
358 dated = save_dir / f"openclaw-vs-nanoclaw-raw-{today}.md"
359 base.write_text("base content", encoding="utf-8")
360 dated.write_text("dated content", encoding="utf-8")
361
362 saved = cli.save_output(report, "md", tmp)
363
364 self.assertEqual((save_dir / f"openclaw-vs-nanoclaw-raw-{today}-1.md").resolve(), saved)
365 self.assertEqual("base content", base.read_text(encoding="utf-8"))
366 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
367 self.assertTrue(saved.exists())
368
369 def test_save_output_render_fn_footer_names_actual_collision_path(self):
370 from lib import render as render_module
371
372 report = self.make_report()
373 with tempfile.TemporaryDirectory() as tmp:
374 save_dir = Path(tmp)
375 today = datetime.now().strftime("%Y-%m-%d")
376 base = save_dir / "openclaw-vs-nanoclaw-raw.md"
377 dated = save_dir / f"openclaw-vs-nanoclaw-raw-{today}.md"
378 base.write_text("base content", encoding="utf-8")
379 dated.write_text("dated content", encoding="utf-8")
380
381 def render_fn(actual_path: Path) -> str:
382 return render_module.render_compact(report, save_path=str(actual_path))
383
384 saved = cli.save_output(report, "md", tmp, render_fn=render_fn)
385
386 expected = (save_dir / f"openclaw-vs-nanoclaw-raw-{today}-1.md").resolve()
387 self.assertEqual(expected, saved.resolve())
388 content = saved.read_text(encoding="utf-8")
389 self.assertIn(f"Raw results saved to {saved}", content)
390 self.assertNotIn(f"Raw results saved to {base}", content)
391 self.assertNotIn(f"Raw results saved to {dated}", content)
392 self.assertEqual("base content", base.read_text(encoding="utf-8"))
393 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
394
395 def test_save_output_removes_reserved_candidate_when_deferred_render_fails(self):
396 report = self.make_report()
397 with tempfile.TemporaryDirectory() as tmp:
398 save_dir = Path(tmp)
399
400 def fail_render(_actual_path: Path) -> str:
401 raise RuntimeError("render failed")
402
403 with self.assertRaisesRegex(RuntimeError, "render failed"):
404 cli.save_output(report, "md", tmp, render_fn=fail_render)
405
406 self.assertEqual([], list(save_dir.iterdir()))
407
408 def test_render_save_and_print_uses_actual_collision_path_in_file_and_stdout(self):
409 report = self.make_report(topic="Collision Topic")
410 with tempfile.TemporaryDirectory() as tmp:
411 save_dir = Path(tmp)
412 today = datetime.now().strftime("%Y-%m-%d")
413 base = save_dir / "collision-topic-raw.md"
414 dated = save_dir / f"collision-topic-raw-{today}.md"
415 expected = save_dir / f"collision-topic-raw-{today}-1.md"
416 base.write_text("base content", encoding="utf-8")
417 dated.write_text("dated content", encoding="utf-8")
418 args = types.SimpleNamespace(
419 topic=["Collision Topic"],
420 competitors=None,
421 competitors_list=None,
422 competitors_plan=None,
423 drill=False,
424 register=None,
425 emit="compact",
426 output=None,
427 save_dir=str(save_dir),
428 save_suffix="",
429 json_profile="agent",
430 publish_html=False,
431 )
432 stdout = io.StringIO()
433 stderr = io.StringIO()
434
435 with redirect_stdout(stdout), redirect_stderr(stderr):
436 rc = cli._render_save_and_print(args, report, None, None, {})
437
438 self.assertEqual(0, rc)
439 expected_display = cli.compute_output_path_display(str(expected))
440 footer = f"Raw results saved to {expected_display}"
441 self.assertTrue(expected.exists())
442 self.assertIn(footer, expected.read_text(encoding="utf-8"))
443 self.assertIn(footer, stdout.getvalue())
444 self.assertEqual("base content", base.read_text(encoding="utf-8"))
445 self.assertEqual("dated content", dated.read_text(encoding="utf-8"))
446
447 def test_save_output_writes_utf8_encoded_markdown(self):
448 report = self.make_report()
449 with tempfile.TemporaryDirectory() as tmp:
450 path = cli.save_output(report, "md", tmp)
451 raw = path.read_bytes()
452 content = path.read_text(encoding="utf-8")
453 self.assertIn(report.topic, content)
454 # Verify the raw bytes decode cleanly as UTF-8.
455 self.assertEqual(content, raw.decode("utf-8"))
456
457 def test_save_rendered_output_writes_exact_file_path(self):
458 with tempfile.TemporaryDirectory() as tmp:
459 out_path = Path(tmp) / "nested" / "results.json"
460 saved = cli.save_rendered_output('{"ok": true}', str(out_path))
461 self.assertEqual(out_path.resolve(), saved)
462 self.assertEqual('{"ok": true}', out_path.read_text(encoding="utf-8"))
463
464 def test_compute_save_path_display_uses_posix_slashes_under_home(self):
465 # Regression: f"~/{relative}" stringified pathlib.Path with the
466 # OS-native separator, producing "~/Documents\\Last30Days\\..." on
467 # Windows that no shell or File Explorer could open. The fix is
468 # f"~/{relative.as_posix()}" which forces forward slashes regardless
469 # of host OS. On POSIX hosts this asserts the contract for
470 # cross-platform safety; on Windows hosts it would fail without the fix.
471 real_home = Path.home()
472 tmp_under_home = Path(tempfile.mkdtemp(prefix="l30d_save_path_", dir=str(real_home)))
473 try:
474 save_dir = tmp_under_home / "Documents" / "Last30Days"
475 save_dir.mkdir(parents=True, exist_ok=True)
476 display = cli.compute_save_path_display(
477 str(save_dir), "british airways middle east", "v3", "compact"
478 )
479 self.assertTrue(display.startswith("~/"), f"Expected '~/' prefix, got: {display}")
480 self.assertNotIn("\\", display, f"Backslash leaked into display: {display}")
481 self.assertTrue(
482 display.endswith("british-airways-middle-east-raw-v3.md"),
483 f"Expected slug+suffix at end, got: {display}",
484 )
485 finally:
486 shutil.rmtree(tmp_under_home, ignore_errors=True)
487
488 def test_compute_output_path_display_uses_posix_slashes_under_home(self):
489 real_home = Path.home()
490 tmp_under_home = Path(tempfile.mkdtemp(prefix="l30d_output_path_", dir=str(real_home)))
491 try:
492 output_path = tmp_under_home / "Documents" / "Last30Days" / "run.json"
493 display = cli.compute_output_path_display(str(output_path))
494 self.assertTrue(display.startswith("~/"), f"Expected '~/' prefix, got: {display}")
495 self.assertNotIn("\\", display, f"Backslash leaked into display: {display}")
496 self.assertTrue(display.endswith("Documents/Last30Days/run.json"), display)
497 finally:
498 shutil.rmtree(tmp_under_home, ignore_errors=True)
499
500 def test_persist_report_updates_run_status_on_success_and_failure(self):
501 report = self.make_report()
502
503 success_store = types.SimpleNamespace(
504 scoped_db=lambda _path: contextlib.nullcontext(),
505 init_db=mock.Mock(),
506 add_topic=mock.Mock(return_value={"id": 7}),
507 record_run=mock.Mock(return_value=11),
508 findings_from_report=mock.Mock(return_value=[{"title": "x"}]),
509 store_findings=mock.Mock(return_value={"new": 2, "updated": 1}),
510 update_run=mock.Mock(),
511 )
512 with mock.patch.dict(sys.modules, {"store": success_store}):
513 counts = cli.persist_report(report)
514 self.assertEqual({"new": 2, "updated": 1}, counts)
515 success_store.update_run.assert_called_once_with(
516 11,
517 status="completed",
518 findings_new=2,
519 findings_updated=1,
520 )
521
522 failure_store = types.SimpleNamespace(
523 scoped_db=lambda _path: contextlib.nullcontext(),
524 init_db=mock.Mock(),
525 add_topic=mock.Mock(return_value={"id": 7}),
526 record_run=mock.Mock(return_value=12),
527 findings_from_report=mock.Mock(side_effect=RuntimeError("boom")),
528 store_findings=mock.Mock(),
529 update_run=mock.Mock(),
530 )
531 with mock.patch.dict(sys.modules, {"store": failure_store}):
532 with self.assertRaises(RuntimeError):
533 cli.persist_report(report)
534 failure_store.update_run.assert_called_once()
535 _, kwargs = failure_store.update_run.call_args
536 self.assertEqual("failed", kwargs["status"])
537 self.assertIn("boom", kwargs["error_message"])
538
539 def test_main_wires_banner_and_progress_display(self):
540 report = self.make_report()
541 diag = {
542 "available_sources": ["grounding", "youtube"],
543 "providers": {"google": True, "openai": False, "xai": False},
544 "x_backend": None,
545 "bird_installed": True,
546 "bird_authenticated": False,
547 "bird_username": None,
548 "native_web_backend": "brave",
549 }
550 fake_progress = mock.Mock()
551 with mock.patch.object(cli.env, "get_config", return_value={}), \
552 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
553 mock.patch.object(cli.pipeline, "run", return_value=report), \
554 mock.patch.object(cli.ui, "show_diagnostic_banner") as banner, \
555 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress) as progress_cls, \
556 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
557 mock.patch.object(sys, "argv", ["last30days.py", "test", "topic"]):
558 stdout = io.StringIO()
559 stderr = io.StringIO()
560 with redirect_stdout(stdout), redirect_stderr(stderr):
561 rc = cli.main()
562 self.assertEqual(0, rc)
563 banner.assert_not_called() # Banner moved to post-research
564 progress_cls.assert_called_once_with("test topic", show_banner=True)
565 fake_progress.start_processing.assert_called_once()
566 fake_progress.end_processing.assert_called_once()
567 fake_progress.show_complete.assert_called_once_with(
568 source_counts={"grounding": 0},
569 display_sources=["grounding"],
570 )
571 fake_progress.show_promo.assert_called_once_with("both", diag=diag)
572 self.assertIn("# rendered", stdout.getvalue())
573
574 def test_main_writes_rendered_output_to_explicit_file(self):
575 report = self.make_report()
576 diag = {
577 "available_sources": ["grounding"],
578 "providers": {"google": True, "openai": False, "xai": False},
579 "x_backend": None,
580 "bird_installed": True,
581 "bird_authenticated": False,
582 "bird_username": None,
583 "native_web_backend": "brave",
584 }
585 with tempfile.TemporaryDirectory() as tmp:
586 output_path = Path(tmp) / "exports" / "run.json"
587 with mock.patch.object(cli.env, "get_config", return_value={}), \
588 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
589 mock.patch.object(cli.pipeline, "run", return_value=report), \
590 mock.patch.object(cli, "emit_output", return_value='{"rendered": true}') as emit, \
591 mock.patch.object(sys, "argv", [
592 "last30days.py",
593 "test",
594 "topic",
595 "--emit=json",
596 "--output",
597 str(output_path),
598 ]):
599 stdout = io.StringIO()
600 stderr = io.StringIO()
601 with redirect_stdout(stdout), redirect_stderr(stderr):
602 rc = cli.main()
603 self.assertEqual(0, rc)
604 emit.assert_called_once()
605 self.assertEqual('{"rendered": true}\n', stdout.getvalue())
606 self.assertEqual('{"rendered": true}', output_path.read_text(encoding="utf-8"))
607 self.assertIn(f"[last30days] Saved output to {output_path.resolve()}", stderr.getvalue())
608
609 def test_main_combines_output_and_save_dir_for_comparison_html(self):
610 diag = {
611 "available_sources": ["grounding"],
612 "providers": {"google": True, "openai": False, "xai": False},
613 "x_backend": None,
614 "bird_installed": True,
615 "bird_authenticated": False,
616 "bird_username": None,
617 "native_web_backend": "brave",
618 }
619 fake_progress = mock.Mock()
620
621 def run_report(*_args, **kwargs):
622 return self.make_report(topic=kwargs["topic"])
623
624 with tempfile.TemporaryDirectory() as tmp:
625 output_path = Path(tmp) / "exports" / "comparison.html"
626 save_dir = Path(tmp) / "saved"
627 with mock.patch.object(cli.env, "get_config", return_value={}), \
628 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
629 mock.patch.object(cli.pipeline, "run", side_effect=run_report), \
630 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
631 mock.patch.object(
632 cli, "emit_comparison_output", return_value="<html>comparison</html>"
633 ) as emit_comparison, \
634 mock.patch.object(cli, "emit_output", return_value="<html>peer</html>"), \
635 mock.patch.object(sys, "argv", [
636 "last30days.py",
637 "Alpha",
638 "vs",
639 "Beta",
640 "--mock",
641 "--emit=html",
642 "--output",
643 str(output_path),
644 "--save-dir",
645 str(save_dir),
646 ]):
647 stdout = io.StringIO()
648 stderr = io.StringIO()
649 with redirect_stdout(stdout), redirect_stderr(stderr):
650 rc = cli.main()
651
652 self.assertEqual(0, rc)
653 output_display = cli.compute_output_path_display(str(output_path))
654 comparison_saved = save_dir / "alpha-vs-beta-raw-html.html"
655 self.assertEqual(2, emit_comparison.call_count)
656 first_kwargs = emit_comparison.call_args_list[0].kwargs
657 second_kwargs = emit_comparison.call_args_list[1].kwargs
658 self.assertEqual(output_display, first_kwargs["save_path"])
659 comparison_display = cli.compute_output_path_display(str(comparison_saved))
660 self.assertEqual(comparison_display, second_kwargs["save_path"])
661 self.assertEqual("<html>comparison</html>\n", stdout.getvalue())
662 self.assertEqual("<html>comparison</html>", output_path.read_text(encoding="utf-8"))
663 self.assertEqual(
664 "<html>comparison</html>",
665 comparison_saved.read_text(encoding="utf-8"),
666 )
667 peer_saved = save_dir / "beta-raw-html.html"
668 self.assertEqual("<html>peer</html>", peer_saved.read_text(encoding="utf-8"))
669 self.assertIn(f"[last30days] Saved output to {output_path.resolve()}", stderr.getvalue())
670 self.assertIn(f"[last30days] Saved output to {comparison_saved.resolve()}", stderr.getvalue())
671 self.assertIn(f"[last30days] Saved output to {peer_saved.resolve()}", stderr.getvalue())
672 self.assertIn(
673 f"[last30days] Comparison artifact set: main={comparison_saved.resolve()}; "
674 f"peers={peer_saved.resolve()}",
675 stderr.getvalue(),
676 )
677
678 def test_main_canonicalizes_explicit_github_repo_flags(self):
679 report = self.make_report()
680 diag = {
681 "available_sources": ["grounding"],
682 "providers": {"google": True, "openai": False, "xai": False},
683 "x_backend": None,
684 "bird_installed": True,
685 "bird_authenticated": False,
686 "bird_username": None,
687 "native_web_backend": "brave",
688 }
689 with mock.patch.object(cli.env, "get_config", return_value={}), \
690 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
691 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
692 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
693 mock.patch.object(sys, "argv", [
694 "last30days.py",
695 "claude",
696 "code",
697 "vs",
698 "codex",
699 "--github-repo",
700 "openai/codex,anthropics/claude-code-action",
701 ]):
702 stdout = io.StringIO()
703 stderr = io.StringIO()
704 with redirect_stdout(stdout), redirect_stderr(stderr):
705 rc = cli.main()
706 self.assertEqual(0, rc)
707 # In vs-mode main + competitors run in parallel via ThreadPoolExecutor,
708 # so the order of pipeline.run invocations is non-deterministic. Find
709 # the main runner's call by predicate on the canonicalized github_repos
710 # rather than by index.
711 expected_repos = ["openai/codex", "anthropics/claude-code"]
712 main_call = next(
713 (c for c in run_mock.call_args_list if c.kwargs.get("github_repos") == expected_repos),
714 None,
715 )
716 self.assertIsNotNone(
717 main_call,
718 f"No pipeline.run call had github_repos={expected_repos}; "
719 f"saw {[c.kwargs.get('github_repos') for c in run_mock.call_args_list]}",
720 )
721 self.assertIn("[GitHub] Canonicalized repos:", stderr.getvalue())
722
723 def test_main_passes_trustpilot_domain_to_pipeline_run(self):
724 """The user-set flag must reach pipeline.run verbatim with
725 provenance user-set (is_hint False) on the single-topic path."""
726 report = self.make_report()
727 diag = {
728 "available_sources": ["grounding"],
729 "providers": {"google": True, "openai": False, "xai": False},
730 "x_backend": None,
731 "bird_installed": True,
732 "bird_authenticated": False,
733 "bird_username": None,
734 "native_web_backend": "brave",
735 }
736 with mock.patch.object(cli.env, "get_config", return_value={}), \
737 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
738 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
739 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
740 mock.patch.object(sys, "argv", [
741 "last30days.py",
742 "ThriftBooks",
743 "--trustpilot-domain",
744 "www.thriftbooks.com",
745 ]):
746 stdout = io.StringIO()
747 stderr = io.StringIO()
748 with redirect_stdout(stdout), redirect_stderr(stderr):
749 rc = cli.main()
750 self.assertEqual(0, rc)
751 main_call = next(
752 (c for c in run_mock.call_args_list
753 if c.kwargs.get("trustpilot_domain") == "www.thriftbooks.com"),
754 None,
755 )
756 self.assertIsNotNone(
757 main_call,
758 f"No pipeline.run call carried trustpilot_domain; saw "
759 f"{[c.kwargs.get('trustpilot_domain') for c in run_mock.call_args_list]}",
760 )
761 self.assertFalse(main_call.kwargs.get("trustpilot_domain_is_hint"))
762
763 def test_trustpilot_domain_auto_activates_include_sources(self):
764 """Explicit --trustpilot-domain must activate Trustpilot even when
765 INCLUDE_SOURCES omits it (#873) — otherwise the flag silently no-ops."""
766 report = self.make_report(topic="Weber grills")
767 diag = {
768 "available_sources": ["tiktok", "instagram"],
769 "providers": {"google": True, "openai": False, "xai": False},
770 "x_backend": None,
771 "bird_installed": True,
772 "bird_authenticated": False,
773 "bird_username": None,
774 "native_web_backend": "brave",
775 }
776 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
777 with mock.patch.object(cli.env, "get_config", return_value=config), \
778 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
779 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
780 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
781 mock.patch.object(sys, "argv", [
782 "last30days.py",
783 "Weber grills",
784 "--trustpilot-domain",
785 "weber.co.uk",
786 ]):
787 stdout = io.StringIO()
788 stderr = io.StringIO()
789 with redirect_stdout(stdout), redirect_stderr(stderr):
790 rc = cli.main()
791 self.assertEqual(0, rc)
792 self.assertIn("trustpilot", config["INCLUDE_SOURCES"].lower())
793 self.assertIn("[Trustpilot] --trustpilot-domain=weber.co.uk activated", stderr.getvalue())
794 main_call = run_mock.call_args_list[0]
795 self.assertEqual(main_call.kwargs.get("trustpilot_domain"), "weber.co.uk")
796
797 def test_trustpilot_domain_auto_activates_with_search_filter(self):
798 """When --search omits trustpilot, the explicit domain flag must still
799 append it to requested_sources so the intersection filter cannot drop it."""
800 report = self.make_report(topic="Weber grills")
801 diag = {
802 "available_sources": ["tiktok", "instagram", "trustpilot"],
803 "providers": {"google": True, "openai": False, "xai": False},
804 "x_backend": None,
805 "bird_installed": True,
806 "bird_authenticated": False,
807 "bird_username": None,
808 "native_web_backend": "brave",
809 }
810 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
811 with mock.patch.object(cli.env, "get_config", return_value=config), \
812 mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
813 mock.patch.object(cli.pipeline, "run", return_value=report) as run_mock, \
814 mock.patch.object(cli, "emit_output", return_value="# rendered"), \
815 mock.patch.object(sys, "argv", [
816 "last30days.py",
817 "Weber grills",
818 "--search",
819 "tiktok,instagram",
820 "--trustpilot-domain",
821 "weber.co.uk",
822 ]):
823 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
824 rc = cli.main()
825 self.assertEqual(0, rc)
826 requested = run_mock.call_args_list[0].kwargs.get("requested_sources") or []
827 self.assertIn("trustpilot", requested)
828
829 def test_trustpilot_domain_respects_exclude_sources(self):
830 config = {"INCLUDE_SOURCES": "tiktok", "EXCLUDE_SOURCES": "trustpilot"}
831 requested = cli.activate_trustpilot_for_explicit_domain(
832 config, ["tiktok"], reason="--trustpilot-domain=weber.co.uk",
833 )
834 self.assertEqual(requested, ["tiktok"])
835 self.assertNotIn("trustpilot", config["INCLUDE_SOURCES"].lower())
836
837
838 class ActivateTrustpilotHelperTests(unittest.TestCase):
839 def test_plan_has_explicit_trustpilot_domain(self):
840 self.assertTrue(cli.plan_has_explicit_trustpilot_domain({
841 "traeger": {"trustpilot_domain": "traeger.com"},
842 }))
843 self.assertFalse(cli.plan_has_explicit_trustpilot_domain({
844 "traeger": {"x_handle": "Traeger"},
845 }))
846 self.assertFalse(cli.plan_has_explicit_trustpilot_domain(None))
847
848 def test_activate_adds_include_and_requested(self):
849 config = {"INCLUDE_SOURCES": "tiktok,instagram"}
850 requested = cli.activate_trustpilot_for_explicit_domain(
851 config, ["tiktok", "instagram"], reason="--trustpilot-domain=x.com",
852 )
853 self.assertIn("trustpilot", config["INCLUDE_SOURCES"].lower())
854 self.assertEqual(requested, ["tiktok", "instagram", "trustpilot"])
855
856 def test_activate_noop_when_already_present(self):
857 config = {"INCLUDE_SOURCES": "tiktok,trustpilot"}
858 requested = cli.activate_trustpilot_for_explicit_domain(
859 config, ["trustpilot"], reason="--trustpilot-domain=x.com",
860 )
861 self.assertEqual(config["INCLUDE_SOURCES"], "tiktok,trustpilot")
862 self.assertEqual(requested, ["trustpilot"])
863
864
865 if __name__ == "__main__":
866 unittest.main()
867
867 lines PYTHON