gabriel / musehub public
test_musehub_ui_issue_detail_ssr.py python
647 lines 19.0 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for the SSR issue detail page — HTMX SSR + comment threading (issue #568).
2
3 Covers server-side rendering of issue body, comment thread, HTMX fragment
4 responses, status action buttons, sidebar, and 404 handling.
5
6 Test areas:
7 Basic rendering
8 - test_issue_detail_renders_title_server_side
9 - test_issue_detail_unknown_number_404
10
11 SSR body content
12 - test_issue_detail_renders_body_markdown
13 - test_issue_detail_empty_body_shows_placeholder
14
15 Comments
16 - test_issue_detail_renders_comments_server_side
17 - test_issue_detail_no_comments_shows_placeholder
18
19 HTMX attributes
20 - test_issue_detail_comment_form_has_hx_post
21 - test_issue_detail_close_button_has_hx_post
22 - test_issue_detail_reopen_button_has_hx_post
23
24 HTMX fragment
25 - test_issue_detail_htmx_request_returns_comment_fragment
26 """
27 from __future__ import annotations
28
29 import pytest
30 from httpx import AsyncClient
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from datetime import datetime, timezone
34
35 from musehub.db.musehub_models import MusehubCommit, MusehubIssue, MusehubIssueComment, MusehubRelease, MusehubRepo
36 from musehub.types.json_types import StrDict
37
38
39 # ---------------------------------------------------------------------------
40 # Helpers
41 # ---------------------------------------------------------------------------
42
43
44 async def _make_repo(
45 db: AsyncSession,
46 owner: str = "songwriter",
47 slug: str = "melodies",
48 ) -> str:
49 """Seed a public repo and return its repo_id string."""
50 repo = MusehubRepo(
51 name=slug,
52 owner=owner,
53 slug=slug,
54 visibility="public",
55 owner_user_id="uid-songwriter",
56 )
57 db.add(repo)
58 await db.commit()
59 await db.refresh(repo)
60 return str(repo.repo_id)
61
62
63 async def _make_issue(
64 db: AsyncSession,
65 repo_id: str,
66 *,
67 number: int = 1,
68 title: str = "Verse needs a bridge",
69 body: str = "The verse feels incomplete.",
70 state: str = "open",
71 author: str = "songwriter",
72 labels: list[str] | None = None,
73 symbol_anchors: list[str] | None = None,
74 ) -> MusehubIssue:
75 """Seed an issue and return it."""
76 issue = MusehubIssue(
77 repo_id=repo_id,
78 number=number,
79 title=title,
80 body=body,
81 state=state,
82 labels=labels or [],
83 symbol_anchors=symbol_anchors or [],
84 author=author,
85 )
86 db.add(issue)
87 await db.commit()
88 await db.refresh(issue)
89 return issue
90
91
92 async def _make_comment(
93 db: AsyncSession,
94 issue_id: str,
95 repo_id: str,
96 *,
97 author: str = "producer",
98 body: str = "Good point.",
99 parent_id: str | None = None,
100 ) -> MusehubIssueComment:
101 """Seed a comment and return it."""
102 comment = MusehubIssueComment(
103 issue_id=issue_id,
104 repo_id=repo_id,
105 author=author,
106 body=body,
107 parent_id=parent_id,
108 )
109 db.add(comment)
110 await db.commit()
111 await db.refresh(comment)
112 return comment
113
114
115 async def _get_detail(
116 client: AsyncClient,
117 number: int = 1,
118 owner: str = "songwriter",
119 slug: str = "melodies",
120 headers: StrDict | None = None,
121 ) -> tuple[int, str]:
122 """Fetch the issue detail page; return (status_code, body_text)."""
123 resp = await client.get(
124 f"/{owner}/{slug}/issues/{number}",
125 headers=headers or {},
126 )
127 return resp.status_code, resp.text
128
129
130 # ---------------------------------------------------------------------------
131 # Basic rendering
132 # ---------------------------------------------------------------------------
133
134
135 async def test_issue_detail_renders_title_server_side(
136 client: AsyncClient,
137 db_session: AsyncSession,
138 ) -> None:
139 """Issue title appears in the HTML rendered on the server."""
140 repo_id = await _make_repo(db_session)
141 await _make_issue(db_session, repo_id, title="Chorus hook is off-key")
142
143 status, body = await _get_detail(client)
144
145 assert status == 200
146 assert "Chorus hook is off-key" in body
147
148
149 async def test_issue_detail_unknown_number_404(
150 client: AsyncClient,
151 db_session: AsyncSession,
152 ) -> None:
153 """A non-existent issue number returns 404."""
154 await _make_repo(db_session)
155
156 resp = await client.get("/songwriter/melodies/issues/999")
157 assert resp.status_code == 404
158
159
160 # ---------------------------------------------------------------------------
161 # SSR body content
162 # ---------------------------------------------------------------------------
163
164
165 async def test_issue_detail_renders_body_markdown(
166 client: AsyncClient,
167 db_session: AsyncSession,
168 ) -> None:
169 """Issue body with Markdown bold is rendered as <strong> in the HTML."""
170 repo_id = await _make_repo(db_session)
171 await _make_issue(db_session, repo_id, body="The **bass line** needs work.")
172
173 status, body = await _get_detail(client)
174
175 assert status == 200
176 assert "<strong>bass line</strong>" in body
177
178
179 async def test_issue_detail_empty_body_shows_placeholder(
180 client: AsyncClient,
181 db_session: AsyncSession,
182 ) -> None:
183 """An issue with empty body renders the 'No description provided' placeholder."""
184 repo_id = await _make_repo(db_session)
185 await _make_issue(db_session, repo_id, body="")
186
187 status, body = await _get_detail(client)
188
189 assert status == 200
190 assert "No description provided" in body
191
192
193 # ---------------------------------------------------------------------------
194 # Comments
195 # ---------------------------------------------------------------------------
196
197
198 async def test_issue_detail_renders_comments_server_side(
199 client: AsyncClient,
200 db_session: AsyncSession,
201 ) -> None:
202 """A seeded comment body appears in the rendered HTML."""
203 repo_id = await _make_repo(db_session)
204 issue = await _make_issue(db_session, repo_id)
205 await _make_comment(db_session, issue.issue_id, repo_id, body="Agreed, bridge it up!")
206
207 status, body = await _get_detail(client)
208
209 assert status == 200
210 assert "Agreed, bridge it up!" in body
211
212
213 async def test_issue_detail_no_comments_shows_placeholder(
214 client: AsyncClient,
215 db_session: AsyncSession,
216 ) -> None:
217 """When there are no comments the placeholder text is rendered."""
218 repo_id = await _make_repo(db_session)
219 await _make_issue(db_session, repo_id)
220
221 status, body = await _get_detail(client)
222
223 assert status == 200
224 assert "No activity yet" in body
225
226
227 # ---------------------------------------------------------------------------
228 # HTMX attributes
229 # ---------------------------------------------------------------------------
230
231
232 async def test_issue_detail_cli_card_shown(
233 client: AsyncClient,
234 db_session: AsyncSession,
235 ) -> None:
236 """The 'Act via CLI' card is rendered with a muse hub issue snippet."""
237 repo_id = await _make_repo(db_session)
238 await _make_issue(db_session, repo_id)
239
240 status, body = await _get_detail(client)
241
242 assert status == 200
243 assert "muse hub issue" in body
244
245
246 async def test_issue_detail_open_state_shows_open_badge(
247 client: AsyncClient,
248 db_session: AsyncSession,
249 ) -> None:
250 """An open issue renders an Open state badge and filed-by attribution."""
251 repo_id = await _make_repo(db_session)
252 await _make_issue(db_session, repo_id, state="open")
253
254 status, body = await _get_detail(client)
255
256 assert status == 200
257 assert "Open" in body
258 assert "filed by" in body
259
260
261 async def test_issue_detail_closed_state_shows_closed_badge(
262 client: AsyncClient,
263 db_session: AsyncSession,
264 ) -> None:
265 """A closed issue renders a Closed state badge."""
266 repo_id = await _make_repo(db_session)
267 await _make_issue(db_session, repo_id, state="closed")
268
269 status, body = await _get_detail(client)
270
271 assert status == 200
272 assert "Closed" in body
273
274
275 # ---------------------------------------------------------------------------
276 # HTMX fragment
277 # ---------------------------------------------------------------------------
278
279
280 async def test_issue_detail_htmx_request_returns_comment_fragment(
281 client: AsyncClient,
282 db_session: AsyncSession,
283 ) -> None:
284 """GET with HX-Request: true returns the comment fragment (no full page shell)."""
285 repo_id = await _make_repo(db_session)
286 issue = await _make_issue(db_session, repo_id)
287 await _make_comment(db_session, issue.issue_id, repo_id, body="Fragment comment here.")
288
289 status, body = await _get_detail(client, headers={"HX-Request": "true"})
290
291 assert status == 200
292 assert "Fragment comment here." in body
293 # Fragment must not include the full page chrome
294 assert "<html" not in body
295 assert "<!DOCTYPE" not in body
296
297
298 # ---------------------------------------------------------------------------
299 # Symbol anchors (Phase 1A)
300 # ---------------------------------------------------------------------------
301
302
303 async def test_symbol_anchors_panel_shown_when_symbol_label_present(
304 client: AsyncClient,
305 db_session: AsyncSession,
306 ) -> None:
307 """Issues with symbol_anchors set display the Symbol Anchors panel."""
308 repo_id = await _make_repo(db_session)
309 await _make_issue(
310 db_session,
311 repo_id,
312 labels=["bug"],
313 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
314 )
315
316 status, body = await _get_detail(client)
317
318 assert status == 200
319 assert "Symbol Anchors" in body
320 assert "compute_snapshot_id" in body
321 assert "muse/core/snapshot.py" in body
322
323
324 async def test_symbol_labels_excluded_from_display_labels(
325 client: AsyncClient,
326 db_session: AsyncSession,
327 ) -> None:
328 """symbol_anchors appear in the anchors panel, not in the regular label list."""
329 repo_id = await _make_repo(db_session)
330 await _make_issue(
331 db_session,
332 repo_id,
333 labels=["performance"],
334 symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"],
335 )
336
337 status, body = await _get_detail(client)
338
339 assert status == 200
340 # The display label 'performance' appears in the page
341 assert "performance" in body
342 # The raw symbol anchor address does NOT appear as a label chip (only in the anchors panel)
343 assert "symbol:muse/core/snapshot.py::compute_snapshot_id" not in body
344
345
346 async def test_symbol_anchors_panel_absent_without_symbol_labels(
347 client: AsyncClient,
348 db_session: AsyncSession,
349 ) -> None:
350 """Issues with no symbol: labels do not show the Symbol Anchors panel."""
351 repo_id = await _make_repo(db_session)
352 await _make_issue(db_session, repo_id, labels=["bug", "performance"])
353
354 status, body = await _get_detail(client)
355
356 assert status == 200
357 assert "Symbol Anchors" not in body
358
359
360 async def test_act_panel_shown(
361 client: AsyncClient,
362 db_session: AsyncSession,
363 ) -> None:
364 """The CLI/MCP/REST act panel is always present on the issue detail page."""
365 repo_id = await _make_repo(db_session)
366 await _make_issue(db_session, repo_id)
367
368 status, body = await _get_detail(client)
369
370 assert status == 200
371 assert "muse hub issue comment" in body
372 assert "create_issue" in body
373 assert "/issues/" in body
374
375
376 # ---------------------------------------------------------------------------
377 # Release card — Muse-native VCS graph release tracking
378 # ---------------------------------------------------------------------------
379
380 _COMMIT_ID_A = "a" * 64
381 _COMMIT_ID_B = "b" * 64
382
383
384 async def _make_commit(
385 db: AsyncSession,
386 repo_id: str,
387 commit_id: str,
388 *,
389 message: str = "fix: resolve the issue",
390 author: str = "gabriel",
391 branch: str = "dev",
392 timestamp: datetime | None = None,
393 ) -> MusehubCommit:
394 commit = MusehubCommit(
395 commit_id=commit_id,
396 repo_id=repo_id,
397 branch=branch,
398 parent_ids=[],
399 message=message,
400 author=author,
401 timestamp=timestamp or datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc),
402 )
403 db.add(commit)
404 await db.commit()
405 await db.refresh(commit)
406 return commit
407
408
409 async def _make_release(
410 db: AsyncSession,
411 repo_id: str,
412 tag: str,
413 commit_id: str,
414 *,
415 semver_major: int = 0,
416 semver_minor: int = 2,
417 semver_patch: int = 1,
418 ) -> MusehubRelease:
419 rel = MusehubRelease(
420 repo_id=repo_id,
421 tag=tag,
422 title=f"Release {tag}",
423 commit_id=commit_id,
424 semver_major=semver_major,
425 semver_minor=semver_minor,
426 semver_patch=semver_patch,
427 channel="stable",
428 )
429 db.add(rel)
430 await db.commit()
431 await db.refresh(rel)
432 return rel
433
434
435 async def test_release_card_no_commits_shows_placeholder(
436 client: AsyncClient,
437 db_session: AsyncSession,
438 ) -> None:
439 """Issue with no commit_anchors shows 'no commits linked' in the release card."""
440 repo_id = await _make_repo(db_session)
441 await _make_issue(db_session, repo_id)
442
443 status, body = await _get_detail(client)
444
445 assert status == 200
446 assert "Release" in body
447 assert "no commits linked" in body
448
449
450 async def test_release_card_shows_commit_hash_when_anchored(
451 client: AsyncClient,
452 db_session: AsyncSession,
453 ) -> None:
454 """Issue with a commit_anchor shows the short hash in the release card."""
455 repo_id = await _make_repo(db_session)
456 commit = await _make_commit(db_session, repo_id, _COMMIT_ID_A, message="fix: buffer overflow")
457 issue = await _make_issue(db_session, repo_id)
458 issue.commit_anchors = [commit.commit_id]
459 await db_session.commit()
460
461 status, body = await _get_detail(client)
462
463 assert status == 200
464 # Short hash (first 8 chars) visible in the release card
465 assert _COMMIT_ID_A[:8] in body
466 # Commit message rendered
467 assert "fix: buffer overflow" in body
468
469
470 async def test_release_card_shows_landed_tag_when_in_release(
471 client: AsyncClient,
472 db_session: AsyncSession,
473 ) -> None:
474 """When an anchor commit is in a tagged release, that release tag is shown."""
475 repo_id = await _make_repo(db_session)
476 # Anchor commit at T=1; release commit at T=2 (after) → anchor is contained.
477 anchor = await _make_commit(
478 db_session, repo_id, _COMMIT_ID_A,
479 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
480 )
481 rel_commit = await _make_commit(
482 db_session, repo_id, _COMMIT_ID_B,
483 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
484 )
485 await _make_release(db_session, repo_id, "v0.2.1", rel_commit.commit_id)
486 issue = await _make_issue(db_session, repo_id)
487 issue.commit_anchors = [anchor.commit_id]
488 await db_session.commit()
489
490 status, body = await _get_detail(client)
491
492 assert status == 200
493 assert "v0.2.1" in body
494
495
496 async def test_release_card_shows_next_tag_when_pending(
497 client: AsyncClient,
498 db_session: AsyncSession,
499 ) -> None:
500 """When commits exist but no release contains them, the next proposed tag is shown."""
501 repo_id = await _make_repo(db_session)
502 # Anchor commit is AFTER the release commit → not yet contained.
503 rel_commit = await _make_commit(
504 db_session, repo_id, _COMMIT_ID_B,
505 timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc),
506 )
507 await _make_release(
508 db_session, repo_id, "v0.2.1", rel_commit.commit_id,
509 semver_major=0, semver_minor=2, semver_patch=1,
510 )
511 anchor = await _make_commit(
512 db_session, repo_id, _COMMIT_ID_A,
513 timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc),
514 )
515 issue = await _make_issue(db_session, repo_id)
516 issue.commit_anchors = [anchor.commit_id]
517 await db_session.commit()
518
519 status, body = await _get_detail(client)
520
521 assert status == 200
522 # Proposed next patch release tag visible
523 assert "v0.2.2" in body
524 assert "proposed" in body
525
526
527 # ---------------------------------------------------------------------------
528 # Intelligence panel — singularity mode (Phase 2B)
529 # ---------------------------------------------------------------------------
530
531
532 async def test_intel_panel_shows_symbol_header(
533 client: AsyncClient,
534 db_session: AsyncSession,
535 ) -> None:
536 """Symbol anchors extracted from labels appear in the Symbol Anchors panel."""
537 repo_id = await _make_repo(db_session)
538 await _make_issue(
539 db_session,
540 repo_id,
541 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
542 )
543
544 status, body = await _get_detail(client)
545
546 assert status == 200
547 # Symbol name rendered in the Symbol Anchors panel (not the Intelligence panel,
548 # which only appears when intel data is indexed for the repo).
549 assert "compute_snapshot_id" in body
550
551
552 async def test_intel_panel_not_yet_indexed_hint_shown(
553 client: AsyncClient,
554 db_session: AsyncSession,
555 ) -> None:
556 """When no symbol index exists, the symbol anchor still appears in the anchors panel."""
557 repo_id = await _make_repo(db_session)
558 await _make_issue(
559 db_session,
560 repo_id,
561 labels=["symbol:muse/core/snapshot.py::build_manifest"],
562 )
563
564 status, body = await _get_detail(client)
565
566 assert status == 200
567 # The symbol address is shown in the Symbol Anchors panel; the Intelligence
568 # panel is hidden when no index exists (no placeholder text is shown).
569 assert "build_manifest" in body
570
571
572 async def test_intel_panel_shows_blast_radius_section(
573 client: AsyncClient,
574 db_session: AsyncSession,
575 ) -> None:
576 """The Intelligence panel renders the Blast radius section."""
577 repo_id = await _make_repo(db_session)
578 await _make_issue(
579 db_session,
580 repo_id,
581 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
582 )
583
584 status, body = await _get_detail(client)
585
586 assert status == 200
587 assert "Blast radius" in body
588
589
590 async def test_intel_panel_shows_open_issues_section(
591 client: AsyncClient,
592 db_session: AsyncSession,
593 ) -> None:
594 """Symbol Anchors panel renders the anchor address for each anchored symbol."""
595 repo_id = await _make_repo(db_session)
596 await _make_issue(
597 db_session,
598 repo_id,
599 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
600 )
601
602 status, body = await _get_detail(client)
603
604 assert status == 200
605 # The anchors panel lists the symbol path — Open issues section was
606 # removed from the Intelligence panel in the flattening refactor.
607 assert "snapshot.py" in body
608
609
610 async def test_intel_panel_open_issues_lists_current_issue_number(
611 client: AsyncClient,
612 db_session: AsyncSession,
613 ) -> None:
614 """The Open issues section includes the current issue's own number as #N."""
615 repo_id = await _make_repo(db_session)
616 await _make_issue(
617 db_session,
618 repo_id,
619 number=7,
620 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
621 state="open",
622 )
623
624 status, body = await _get_detail(client, number=7)
625
626 assert status == 200
627 assert "#7" in body
628
629
630 async def test_intel_placeholder_shown_when_no_intel(
631 client: AsyncClient,
632 db_session: AsyncSession,
633 ) -> None:
634 """Page renders correctly when symbol anchors exist but no intel index is built."""
635 repo_id = await _make_repo(db_session)
636 await _make_issue(
637 db_session,
638 repo_id,
639 labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"],
640 )
641
642 status, body = await _get_detail(client)
643
644 assert status == 200
645 # Symbol anchor appears in the Symbol Anchors panel; the Intelligence panel
646 # is hidden when no index exists (the flattened design omits the placeholder).
647 assert "compute_snapshot_id" in body
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago