返回 last30days-skill
test_html_render.py
根目录 / tests / test_html_render.py
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("&lt;img src=x onerror=alert(1)&gt;", 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">&lt;img src=x onerror=alert(1)&gt;</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
395 lines PYTHON