| 1 | """Tests for the HTML emit renderer.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import tempfile |
| 6 | import unittest |
| 7 | from html.parser import HTMLParser |
| 8 | from pathlib import Path |
| 9 | |
| 10 | import last30days as cli |
| 11 | from lib import html_render, schema |
| 12 | |
| 13 | |
| 14 | def _report(topic: str, cluster_titles: list[str]) -> schema.Report: |
| 15 | items: list[schema.SourceItem] = [] |
| 16 | candidates: list[schema.Candidate] = [] |
| 17 | clusters: list[schema.Cluster] = [] |
| 18 | |
| 19 | for index, title in enumerate(cluster_titles, start=1): |
| 20 | item = schema.SourceItem( |
| 21 | item_id=f"item-{index}", |
| 22 | source="grounding", |
| 23 | title=title, |
| 24 | body=f"Body for {title}", |
| 25 | url=f"https://example.test/{index}", |
| 26 | container="example.test", |
| 27 | published_at="2026-04-20", |
| 28 | date_confidence="high", |
| 29 | engagement={"views": index * 100}, |
| 30 | snippet=f"Snippet for {title}", |
| 31 | ) |
| 32 | candidate = schema.Candidate( |
| 33 | candidate_id=f"candidate-{index}", |
| 34 | item_id=item.item_id, |
| 35 | source="grounding", |
| 36 | title=title, |
| 37 | url=item.url, |
| 38 | snippet=item.snippet, |
| 39 | subquery_labels=["primary"], |
| 40 | native_ranks={"primary:grounding": index}, |
| 41 | local_relevance=0.9, |
| 42 | freshness=80, |
| 43 | engagement=50, |
| 44 | source_quality=1.0, |
| 45 | rrf_score=0.5, |
| 46 | final_score=90 - index, |
| 47 | sources=["grounding"], |
| 48 | source_items=[item], |
| 49 | ) |
| 50 | cluster = schema.Cluster( |
| 51 | cluster_id=f"cluster-{index}", |
| 52 | title=title, |
| 53 | candidate_ids=[candidate.candidate_id], |
| 54 | representative_ids=[candidate.candidate_id], |
| 55 | sources=["grounding"], |
| 56 | score=90 - index, |
| 57 | ) |
| 58 | items.append(item) |
| 59 | candidates.append(candidate) |
| 60 | clusters.append(cluster) |
| 61 | |
| 62 | return schema.Report( |
| 63 | topic=topic, |
| 64 | range_from="2026-03-30", |
| 65 | range_to="2026-04-29", |
| 66 | generated_at="2026-04-29T12:00:00+00:00", |
| 67 | provider_runtime=schema.ProviderRuntime( |
| 68 | reasoning_provider="local", |
| 69 | planner_model="mock-planner", |
| 70 | rerank_model="mock-rerank", |
| 71 | ), |
| 72 | query_plan=schema.QueryPlan( |
| 73 | intent="research", |
| 74 | freshness_mode="balanced_recent", |
| 75 | cluster_mode="story", |
| 76 | raw_topic=topic, |
| 77 | subqueries=[ |
| 78 | schema.SubQuery( |
| 79 | label="primary", |
| 80 | search_query=topic, |
| 81 | ranking_query=topic, |
| 82 | sources=["grounding"], |
| 83 | ) |
| 84 | ], |
| 85 | source_weights={"grounding": 1.0}, |
| 86 | ), |
| 87 | clusters=clusters, |
| 88 | ranked_candidates=candidates, |
| 89 | items_by_source={"grounding": items}, |
| 90 | errors_by_source={}, |
| 91 | artifacts={"pre_research_flags_present": True}, |
| 92 | ) |
| 93 | |
| 94 | |
| 95 | def _assert_parses(test_case: unittest.TestCase, html: str) -> None: |
| 96 | parser = HTMLParser() |
| 97 | parser.feed(html) |
| 98 | parser.close() |
| 99 | test_case.assertIn("</html>", html) |
| 100 | |
| 101 | |
| 102 | class HtmlRenderSnapshotTests(unittest.TestCase): |
| 103 | def test_rich_cluster_fixture_snapshot(self): |
| 104 | rendered = html_render.render_html( |
| 105 | _report("AI agent frameworks", ["OpenClaw ships containers", "Skills marketplace grows"]) |
| 106 | ) |
| 107 | snapshot_markers = [ |
| 108 | "<!DOCTYPE html>", |
| 109 | "<title>last30days · AI agent frameworks</title>", |
| 110 | '<div class="badge"><span class="accent">🌐</span> last30days v', |
| 111 | '<div class="meta">2026-03-30 to 2026-04-29', |
| 112 | '<div class="engine-footer"><pre>---\n✅ All agents reported back!', |
| 113 | 'Generated 2026-04-29 by /last30days v', |
| 114 | '<span class="rerun">/last30days AI agent frameworks</span>', |
| 115 | ] |
| 116 | for marker in snapshot_markers: |
| 117 | self.assertIn(marker, rendered) |
| 118 | self.assertNotIn("EVIDENCE FOR SYNTHESIS", rendered) |
| 119 | self.assertNotIn("END OF last30days CANONICAL OUTPUT", rendered) |
| 120 | |
| 121 | def test_thin_cluster_fixture_snapshot(self): |
| 122 | rendered = html_render.render_html(_report("obscure topic", [])) |
| 123 | snapshot_markers = [ |
| 124 | "<title>last30days · obscure topic</title>", |
| 125 | "no active sources", |
| 126 | "topic: obscure topic", |
| 127 | ] |
| 128 | for marker in snapshot_markers: |
| 129 | self.assertIn(marker, rendered) |
| 130 | |
| 131 | def test_comparison_mode_snapshot(self): |
| 132 | reports = [ |
| 133 | ("OpenClaw", _report("OpenClaw", ["Containers"])), |
| 134 | ("Hermes", _report("Hermes", ["Memory"])), |
| 135 | ] |
| 136 | rendered = html_render.render_html_comparison(reports) |
| 137 | snapshot_markers = [ |
| 138 | "<title>last30days · OpenClaw vs Hermes</title>", |
| 139 | 'comparing 2: OpenClaw, Hermes</div>', |
| 140 | '<div class="meta">2026-03-30 to 2026-04-29', |
| 141 | '<span class="rerun">/last30days OpenClaw vs Hermes</span>', |
| 142 | ] |
| 143 | for marker in snapshot_markers: |
| 144 | self.assertIn(marker, rendered) |
| 145 | |
| 146 | |
| 147 | class HtmlRenderBehaviorTests(unittest.TestCase): |
| 148 | def test_prose_label_promotion(self): |
| 149 | md = html_render._promote_prose_labels("What I learned:") |
| 150 | rendered = html_render._markdown_to_html(md) |
| 151 | self.assertIn("<h2>What I learned</h2>", rendered) |
| 152 | self.assertNotIn("What I learned:", rendered) |
| 153 | |
| 154 | def test_invitation_strip(self): |
| 155 | md = "---\nI'm now an expert on OpenClaw. Some things you could ask:\n\nJust ask." |
| 156 | self.assertNotIn("I'm now an expert", html_render._strip_invitation(md)) |
| 157 | |
| 158 | def test_evidence_block_strip(self): |
| 159 | md = "keep\n<!-- EVIDENCE FOR SYNTHESIS -->\nsecret\n<!-- END EVIDENCE FOR SYNTHESIS -->" |
| 160 | stripped = html_render._strip_evidence_block(md) |
| 161 | self.assertIn("keep", stripped) |
| 162 | self.assertNotIn("EVIDENCE FOR SYNTHESIS", stripped) |
| 163 | self.assertNotIn("secret", stripped) |
| 164 | |
| 165 | def test_engine_footer_wrapping_preserves_tree(self): |
| 166 | md = ( |
| 167 | "<!-- PASS-THROUGH FOOTER: emit verbatim. -->\n" |
| 168 | "✅ All agents reported back!\n" |
| 169 | "├─ 🔵 X: 2 posts\n" |
| 170 | "└─ 🌐 Web: 1 result\n" |
| 171 | "<!-- END PASS-THROUGH FOOTER -->" |
| 172 | ) |
| 173 | body = html_render._wrap_engine_footer(html_render._markdown_to_html(md)) |
| 174 | self.assertIn('<div class="engine-footer"><pre>✅ All agents reported back!', body) |
| 175 | self.assertIn("├─ 🔵 X: 2 posts", body) |
| 176 | self.assertIn("└─ 🌐 Web: 1 result", body) |
| 177 | |
| 178 | def test_colophon_contains_topic_and_rerun_command(self): |
| 179 | rendered = html_render.render_html(_report("AI agent frameworks", [])) |
| 180 | self.assertIn("topic: AI agent frameworks", rendered) |
| 181 | self.assertIn("/last30days AI agent frameworks", rendered) |
| 182 | |
| 183 | def test_parseability(self): |
| 184 | _assert_parses(self, html_render.render_html(_report("parse me", ["One"]))) |
| 185 | |
| 186 | def test_self_containedness(self): |
| 187 | rendered = html_render.render_html(_report("self contained", [])) |
| 188 | self.assertNotIn("<script", rendered.lower()) |
| 189 | self.assertEqual(0, rendered.count('rel="stylesheet"')) |
| 190 | self.assertNotIn("fonts.googleapis.com", rendered) |
| 191 | self.assertNotIn("fonts.gstatic.com", rendered) |
| 192 | self.assertNotIn('rel="preconnect"', rendered) |
| 193 | |
| 194 | def test_markdown_links_convert(self): |
| 195 | rendered = html_render._markdown_to_html("[name](https://example.test/path)") |
| 196 | self.assertIn( |
| 197 | '<a href="https://example.test/path" rel="noopener noreferrer">name</a>', |
| 198 | rendered, |
| 199 | ) |
| 200 | |
| 201 | def test_markdown_links_reject_javascript_scheme(self): |
| 202 | """A `[label](javascript:...)` link must NOT render as a clickable href. |
| 203 | |
| 204 | The HTML artifact is opened in a browser, so allowing arbitrary URL |
| 205 | schemes turns synthesized markdown into a stored-XSS surface. External |
| 206 | sources (Reddit, X, HN, etc.) can plant such links and the LLM may |
| 207 | carry them through synthesis; the renderer is the last gate. |
| 208 | """ |
| 209 | for payload in ( |
| 210 | "[click](javascript:alert(1))", |
| 211 | "[click](JAVASCRIPT:alert(1))", |
| 212 | "[click](vbscript:alert(1))", |
| 213 | "[click](file:///etc/passwd)", |
| 214 | ): |
| 215 | rendered = html_render._markdown_to_html(payload) |
| 216 | self.assertNotIn("<a ", rendered, msg=f"payload accepted: {payload}") |
| 217 | self.assertNotIn("href=", rendered, msg=f"payload accepted: {payload}") |
| 218 | # The label still surfaces as plain text so context isn't lost. |
| 219 | self.assertIn("click", rendered, msg=f"label dropped: {payload}") |
| 220 | |
| 221 | def test_markdown_links_reject_data_uri(self): |
| 222 | rendered = html_render._markdown_to_html( |
| 223 | "[click](data:text/html,<svg/onload=alert(1)>)" |
| 224 | ) |
| 225 | self.assertNotIn("<a ", rendered) |
| 226 | self.assertNotIn("href=", rendered) |
| 227 | # The label still surfaces as plain text so context isn't lost. |
| 228 | self.assertIn("click", rendered) |
| 229 | |
| 230 | def test_markdown_links_strip_leading_whitespace_javascript(self): |
| 231 | """Leading whitespace in the URL must not let a `javascript:` payload |
| 232 | bypass the scheme check (browsers strip leading whitespace before |
| 233 | parsing the scheme).""" |
| 234 | rendered = html_render._markdown_to_html("[click](\tjavascript:alert(1))") |
| 235 | # `[^)\\s]+` already rejects whitespace inside the URL, so the regex |
| 236 | # doesn't match and the label is left as plain text. The point of the |
| 237 | # assertion is to pin that behavior: NO `<a href>` is produced. |
| 238 | self.assertNotIn("<a ", rendered) |
| 239 | self.assertNotIn("href=", rendered) |
| 240 | |
| 241 | def test_meta_marker_escapes_payload_through_pipeline(self): |
| 242 | """A META marker carrying markup must not render as live HTML. |
| 243 | |
| 244 | The marker is exempted from the comment-strip pass and promoted into a |
| 245 | <div class="meta">. Its text can come from LLM-synthesized content |
| 246 | derived from untrusted source bodies, so a crafted |
| 247 | `<!-- META: <img src=x onerror=...> -->` must be escaped, not rendered. |
| 248 | """ |
| 249 | md = "intro\n\n<!-- META: <img src=x onerror=alert(1)> -->\n\nmore" |
| 250 | body = html_render._markdown_to_html(md) |
| 251 | body = html_render._wrap_engine_footer(body) |
| 252 | body = html_render._promote_meta_marker(body) |
| 253 | self.assertNotIn("<img", body) |
| 254 | self.assertIn("<img src=x onerror=alert(1)>", body) |
| 255 | |
| 256 | def test_meta_marker_escapes_raw_fallback(self): |
| 257 | """The raw (unescaped) META fallback path must also escape its payload.""" |
| 258 | body = html_render._promote_meta_marker( |
| 259 | "<!-- META: <img src=x onerror=alert(1)> -->" |
| 260 | ) |
| 261 | self.assertNotIn("<img", body) |
| 262 | self.assertEqual( |
| 263 | body, '<div class="meta"><img src=x onerror=alert(1)></div>' |
| 264 | ) |
| 265 | |
| 266 | def test_meta_marker_preserves_plain_text(self): |
| 267 | """Legitimate date/source-name markers render unchanged (no double-escape).""" |
| 268 | body = html_render._promote_meta_marker( |
| 269 | "<!-- META: 2026-01-01 to 2026-01-31 · reddit, x -->" |
| 270 | ) |
| 271 | self.assertEqual( |
| 272 | body, |
| 273 | '<div class="meta">2026-01-01 to 2026-01-31 · reddit, x</div>', |
| 274 | ) |
| 275 | |
| 276 | def test_markdown_links_allow_relative_url(self): |
| 277 | rendered = html_render._markdown_to_html("[home](/path?x=1#section)") |
| 278 | self.assertIn( |
| 279 | '<a href="/path?x=1#section" rel="noopener noreferrer">home</a>', |
| 280 | rendered, |
| 281 | ) |
| 282 | |
| 283 | def test_markdown_links_allow_mailto(self): |
| 284 | rendered = html_render._markdown_to_html("[mail](mailto:a@example.com)") |
| 285 | self.assertIn( |
| 286 | '<a href="mailto:a@example.com" rel="noopener noreferrer">mail</a>', |
| 287 | rendered, |
| 288 | ) |
| 289 | |
| 290 | def test_no_file_header_h1(self): |
| 291 | rendered = html_render.render_html(_report("AI agent frameworks", ["One"])) |
| 292 | self.assertNotIn("<h1>last30days v", rendered) |
| 293 | |
| 294 | def test_no_safety_note(self): |
| 295 | rendered = html_render.render_html(_report("AI agent frameworks", ["One"])) |
| 296 | self.assertNotIn("Safety note", rendered) |
| 297 | |
| 298 | def test_synthesis_md_embedded(self): |
| 299 | synthesis = "**Test brief** - body content per [@example](https://example.com)" |
| 300 | rendered = html_render.render_html( |
| 301 | _report("AI agent frameworks", ["One"]), |
| 302 | synthesis_md=synthesis, |
| 303 | ) |
| 304 | self.assertIn("<strong>Test brief</strong> - body content per", rendered) |
| 305 | self.assertIn( |
| 306 | '<a href="https://example.com" rel="noopener noreferrer">@example</a>', |
| 307 | rendered, |
| 308 | ) |
| 309 | metadata_index = rendered.index('<div class="meta">') |
| 310 | synthesis_index = rendered.index("<strong>Test brief</strong>") |
| 311 | footer_index = rendered.index('<div class="engine-footer">') |
| 312 | self.assertLess(metadata_index, synthesis_index) |
| 313 | self.assertLess(synthesis_index, footer_index) |
| 314 | |
| 315 | def test_warnings_excluded_from_html_artifact(self): |
| 316 | """Data quality warnings must NOT appear in the shareable HTML. |
| 317 | |
| 318 | Recipients of a shared HTML brief don't have context to act on |
| 319 | warnings about pre-flight resolution / engine state. The HTML is the |
| 320 | artifact; warnings stay in the engine's stderr logs where the |
| 321 | generator (not the recipient) sees them. |
| 322 | """ |
| 323 | report = _report("OpenClaw", ["Containers"]) |
| 324 | report.artifacts["pre_research_flags_present"] = False |
| 325 | report.artifacts["plan_source"] = "deterministic" |
| 326 | report.warnings.append("Brave quota exhausted") |
| 327 | rendered = html_render.render_html(report) |
| 328 | # Warning text variations must all be absent from the artifact. |
| 329 | self.assertNotIn("Data quality note", rendered) |
| 330 | self.assertNotIn("Brave quota exhausted", rendered) |
| 331 | self.assertNotIn("DEGRADED RUN WARNING", rendered) |
| 332 | self.assertNotIn("Pre-Research Status", rendered) |
| 333 | # No blockquote at all in mock output - just badge + meta + footer + colophon |
| 334 | self.assertEqual(0, rendered.count("<blockquote>")) |
| 335 | |
| 336 | |
| 337 | class HtmlCliIntegrationTests(unittest.TestCase): |
| 338 | def test_parser_accepts_html_emit(self): |
| 339 | args = cli.build_parser().parse_args(["AI agents", "--emit=html"]) |
| 340 | self.assertEqual("html", args.emit) |
| 341 | |
| 342 | def test_synthesis_file_cli(self): |
| 343 | synthesis = "**Test brief** - body content per [@example](https://example.com)" |
| 344 | with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp: |
| 345 | tmp.write(synthesis) |
| 346 | tmp_path = tmp.name |
| 347 | try: |
| 348 | args = cli.build_parser().parse_args([ |
| 349 | "OpenClaw", |
| 350 | "--mock", |
| 351 | "--emit=html", |
| 352 | "--synthesis-file", |
| 353 | tmp_path, |
| 354 | ]) |
| 355 | rendered = cli.emit_output( |
| 356 | _report("OpenClaw", ["Containers"]), |
| 357 | args.emit, |
| 358 | synthesis_md=cli.read_synthesis_file(args.synthesis_file), |
| 359 | ) |
| 360 | finally: |
| 361 | Path(tmp_path).unlink(missing_ok=True) |
| 362 | self.assertIn("<strong>Test brief</strong> - body content per", rendered) |
| 363 | |
| 364 | def test_save_output_uses_raw_html_extension_and_suffix(self): |
| 365 | report = _report("AI Agent Frameworks", []) |
| 366 | with self.subTest("plain"): |
| 367 | path = cli.compute_save_path_display("/tmp", report.topic, "", "html") |
| 368 | self.assertTrue(path.endswith("/ai-agent-frameworks-raw-html.html")) |
| 369 | with self.subTest("suffix"): |
| 370 | path = cli.compute_save_path_display("/tmp", report.topic, "v3", "html") |
| 371 | self.assertTrue(path.endswith("/ai-agent-frameworks-raw-html-v3.html")) |
| 372 | |
| 373 | def test_save_output_can_persist_comparison_html(self): |
| 374 | reports = [ |
| 375 | ("OpenClaw", _report("OpenClaw", ["Containers"])), |
| 376 | ("Hermes", _report("Hermes", ["Memory"])), |
| 377 | ] |
| 378 | rendered = cli.emit_comparison_output(reports, "html") |
| 379 | with tempfile.TemporaryDirectory() as tmpdir: |
| 380 | path = cli.save_output( |
| 381 | reports[0][1], |
| 382 | "html", |
| 383 | tmpdir, |
| 384 | topic_override=cli.comparison_topic(reports), |
| 385 | rendered_content=rendered, |
| 386 | ) |
| 387 | self.assertEqual("openclaw-vs-hermes-raw-html.html", path.name) |
| 388 | saved = path.read_text(encoding="utf-8") |
| 389 | self.assertIn("last30days · OpenClaw vs Hermes", saved) |
| 390 | self.assertIn("comparing 2: OpenClaw, Hermes", saved) |
| 391 | self.assertNotIn("last30days · OpenClaw</title>", saved) |
| 392 | |
| 393 | if __name__ == "__main__": |
| 394 | unittest.main() |
| 395 |