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