gabriel / musehub public
test_musehub_ui_graph_blob_ssr.py python
389 lines 11.9 KB
33b91dcc fix: consolidate to 4-color intent palette (#39) Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """SSR tests for the graph (DAG) page and blob viewer — issue #584.
2
3 Verifies that:
4 - graph_page() injects DAG data via the ``page_json`` block (``<script
5 type="application/json" id="page-data">``) so HTMX navigation re-reads it on
6 every swap without relying on ``window.*`` globals that are only set once.
7 - blob_page() renders text file content (line-numbered table) server-side.
8 - blob_page() renders MIDI player shell with data-midi-url.
9 - blob_page() renders binary download link when file is binary.
10 - blob_page() returns 200 with blob_found=False context when object is absent (not 404,
11 since the page itself is valid — the JS fallback handles missing files).
12
13 Covers:
14 - test_graph_page_sets_graph_data_js_global
15 - test_graph_page_shows_commit_count
16 - test_blob_page_renders_file_content_server_side
17 - test_blob_page_renders_line_numbers
18 - test_blob_page_shows_file_size
19 - test_blob_page_binary_shows_download_link
20 - test_blob_page_midi_shows_player_shell
21 - test_blob_page_unknown_path_no_ssr
22 """
23 from __future__ import annotations
24
25 import os
26 import uuid
27 from datetime import datetime, timezone
28
29 import pytest
30 from httpx import AsyncClient
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo
34
35 # ---------------------------------------------------------------------------
36 # Constants
37 # ---------------------------------------------------------------------------
38
39 _OWNER = "graphblobssr584"
40 _SLUG = "graph-blob-ssr-584"
41
42 # ---------------------------------------------------------------------------
43 # Seed helpers
44 # ---------------------------------------------------------------------------
45
46
47 async def _seed_repo(db: AsyncSession) -> str:
48 """Seed a public repo and return its repo_id string."""
49 repo = MusehubRepo(
50 repo_id=str(uuid.uuid4()),
51 name=_SLUG,
52 owner=_OWNER,
53 slug=_SLUG,
54 visibility="public",
55 owner_user_id=str(uuid.uuid4()),
56 )
57 db.add(repo)
58 await db.flush()
59 return str(repo.repo_id)
60
61
62 async def _seed_commit(
63 db: AsyncSession,
64 repo_id: str,
65 *,
66 commit_id: str | None = None,
67 author: str = "alice",
68 message: str = "Initial commit",
69 branch: str = "main",
70 ) -> str:
71 """Seed a commit row and return the commit_id."""
72 cid = commit_id or (uuid.uuid4().hex + uuid.uuid4().hex)[:40]
73 db.add(
74 MusehubCommit(
75 commit_id=cid,
76 repo_id=repo_id,
77 branch=branch,
78 parent_ids=[],
79 message=message,
80 author=author,
81 timestamp=datetime.now(timezone.utc),
82 snapshot_id=None,
83 )
84 )
85 await db.flush()
86 return cid
87
88
89 async def _seed_branch(db: AsyncSession, repo_id: str, head_id: str, name: str = "main") -> None:
90 """Seed a branch row."""
91 db.add(MusehubBranch(repo_id=repo_id, name=name, head_commit_id=head_id))
92 await db.flush()
93
94
95 async def _seed_object(
96 db: AsyncSession,
97 repo_id: str,
98 *,
99 path: str,
100 disk_path: str,
101 size_bytes: int = 0,
102 ) -> str:
103 """Seed a MusehubObject row and return its object_id."""
104 oid = "sha256:" + uuid.uuid4().hex
105 db.add(
106 MusehubObject(
107 object_id=oid,
108 repo_id=repo_id,
109 path=path,
110 size_bytes=size_bytes,
111 disk_path=disk_path,
112 )
113 )
114 await db.flush()
115 return oid
116
117
118 # ---------------------------------------------------------------------------
119 # Graph page tests
120 # ---------------------------------------------------------------------------
121
122
123 @pytest.mark.anyio
124 async def test_graph_page_sets_graph_data_js_global(
125 client: AsyncClient,
126 db_session: AsyncSession,
127 ) -> None:
128 """DAG config and data are injected into the page_json block server-side.
129
130 The graph renderer reads ``<script type="application/json" id="page-data">``
131 on every load — including HTMX partial swaps — so no ``window.*`` globals
132 are needed. The SSR contract is that ``"page": "graph"`` and ``"repoId"``
133 appear inside that JSON block.
134 """
135 repo_id = await _seed_repo(db_session)
136 cid = await _seed_commit(db_session, repo_id)
137 await _seed_branch(db_session, repo_id, cid)
138
139 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
140 assert response.status_code == 200
141 assert '"page": "graph"' in response.text
142 assert '"repoId"' in response.text
143
144
145 @pytest.mark.anyio
146 async def test_graph_page_shows_commit_count(
147 client: AsyncClient,
148 db_session: AsyncSession,
149 ) -> None:
150 """Commit count appears in the server-rendered HTML header.
151
152 The count must be visible before JavaScript runs so users and crawlers see
153 accurate metadata.
154 """
155 repo_id = await _seed_repo(db_session)
156 cid1 = await _seed_commit(db_session, repo_id, message="First commit")
157 cid2 = await _seed_commit(db_session, repo_id, message="Second commit")
158 await _seed_branch(db_session, repo_id, cid2)
159
160 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
161 assert response.status_code == 200
162 # Commit count is rendered server-side as a number in the shared stat strip
163 assert "ph-stat-value" in response.text
164
165
166 @pytest.mark.anyio
167 async def test_graph_page_shows_branch_count(
168 client: AsyncClient,
169 db_session: AsyncSession,
170 ) -> None:
171 """Branch count appears in the server-rendered HTML header."""
172 repo_id = await _seed_repo(db_session)
173 cid = await _seed_commit(db_session, repo_id)
174 await _seed_branch(db_session, repo_id, cid, name="main")
175 await _seed_branch(db_session, repo_id, cid, name="feat/jazz")
176
177 response = await client.get(f"/{_OWNER}/{_SLUG}/graph")
178 assert response.status_code == 200
179 # Branch count is rendered server-side in the shared stats strip
180 assert "ph-stat-value" in response.text
181
182
183 # ---------------------------------------------------------------------------
184 # Blob page tests
185 # ---------------------------------------------------------------------------
186
187
188 @pytest.mark.anyio
189 async def test_blob_page_renders_file_content_server_side(
190 client: AsyncClient,
191 db_session: AsyncSession,
192 ) -> None:
193 """Text file content is rendered in the initial HTML without JS.
194
195 The line-numbered table must be present in the server response body so
196 non-JS clients can read file content.
197 """
198 import tempfile as _tempfile
199
200 repo_id = await _seed_repo(db_session)
201
202 # Write a real file so blob_page() can read its content.
203 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
204 disk.write("print('hello')\n")
205 disk.close()
206
207 try:
208 await _seed_object(
209 db_session,
210 repo_id,
211 path="main.py",
212 disk_path=disk.name,
213 size_bytes=16,
214 )
215
216 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/main.py")
217 assert response.status_code == 200
218 body = response.text
219 # File content must appear in the SSR HTML (inside the line table)
220 assert "print" in body
221 assert "hello" in body
222 finally:
223 os.unlink(disk.name)
224
225
226 @pytest.mark.anyio
227 async def test_blob_page_renders_line_numbers(
228 client: AsyncClient,
229 db_session: AsyncSession,
230 ) -> None:
231 """Line number anchors (#L1) are present in the server-rendered table.
232
233 Allows direct linking to individual lines without JavaScript.
234 """
235 import tempfile as _tempfile
236
237 repo_id = await _seed_repo(db_session)
238
239 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False)
240 disk.write("line one\nline two\n")
241 disk.close()
242
243 try:
244 await _seed_object(
245 db_session,
246 repo_id,
247 path="code.py",
248 disk_path=disk.name,
249 size_bytes=18,
250 )
251
252 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/code.py")
253 assert response.status_code == 200
254 body = response.text
255 assert 'id="L1"' in body
256 assert 'href="#L1"' in body
257 finally:
258 os.unlink(disk.name)
259
260
261 @pytest.mark.anyio
262 async def test_blob_page_shows_file_size(
263 client: AsyncClient,
264 db_session: AsyncSession,
265 ) -> None:
266 """File size appears in the server-rendered blob header.
267
268 Users can see the file size immediately without waiting for the JS fetch.
269 """
270 import tempfile as _tempfile
271
272 repo_id = await _seed_repo(db_session)
273
274 disk = _tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
275 disk.write("a" * 2048)
276 disk.close()
277
278 try:
279 await _seed_object(
280 db_session,
281 repo_id,
282 path="readme.txt",
283 disk_path=disk.name,
284 size_bytes=2048,
285 )
286
287 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/readme.txt")
288 assert response.status_code == 200
289 # filesizeformat renders 2048 bytes as "2.0 KB"
290 assert "2.0 KB" in response.text
291 finally:
292 os.unlink(disk.name)
293
294
295 @pytest.mark.anyio
296 async def test_blob_page_binary_shows_download_link(
297 client: AsyncClient,
298 db_session: AsyncSession,
299 ) -> None:
300 """Binary files show a download link instead of a line-numbered table.
301
302 The download link is server-rendered so non-JS clients can retrieve the
303 file even when the hex-dump JS renderer is unavailable.
304 """
305 import tempfile as _tempfile
306
307 repo_id = await _seed_repo(db_session)
308
309 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".webp", delete=False)
310 disk.write(b"\x00\x01\x02\x03")
311 disk.close()
312
313 try:
314 await _seed_object(
315 db_session,
316 repo_id,
317 path="image.webp",
318 disk_path=disk.name,
319 size_bytes=4,
320 )
321
322 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/image.webp")
323 assert response.status_code == 200
324 body = response.text
325 # SSR renders binary download link, no line table
326 assert "Download raw" in body or "download" in body.lower()
327 assert 'id="L1"' not in body
328 finally:
329 os.unlink(disk.name)
330
331
332 @pytest.mark.anyio
333 async def test_blob_page_midi_shows_player_shell(
334 client: AsyncClient,
335 db_session: AsyncSession,
336 ) -> None:
337 """MIDI files render a player shell with data-midi-url set server-side.
338
339 The ``#midi-player`` div and its ``data-midi-url`` attribute must be
340 present in the initial HTML so a JS MIDI player can attach without an
341 extra API call to discover the raw URL.
342 """
343 import tempfile as _tempfile
344
345 repo_id = await _seed_repo(db_session)
346
347 disk = _tempfile.NamedTemporaryFile(mode="wb", suffix=".mid", delete=False)
348 disk.write(b"MThd")
349 disk.close()
350
351 try:
352 await _seed_object(
353 db_session,
354 repo_id,
355 path="track.mid",
356 disk_path=disk.name,
357 size_bytes=4,
358 )
359
360 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/track.mid")
361 assert response.status_code == 200
362 body = response.text
363 assert "midi-player" in body
364 assert "data-midi-url" in body
365 finally:
366 os.unlink(disk.name)
367
368
369 @pytest.mark.anyio
370 async def test_blob_page_unknown_path_no_ssr(
371 client: AsyncClient,
372 db_session: AsyncSession,
373 ) -> None:
374 """A path with no matching object returns 200 with blob_found=false.
375
376 The page shell renders but no SSR blob content is present — the JS
377 fallback fetches metadata and shows an appropriate error. We do NOT
378 raise a 404 at the UI layer so that the page chrome (nav, breadcrumb)
379 stays intact for the user.
380 """
381 await _seed_repo(db_session)
382
383 response = await client.get(f"/{_OWNER}/{_SLUG}/blob/main/nonexistent.py")
384 assert response.status_code == 200
385 # No SSR blob content block when object is absent — the id="blob-ssr-content" div
386 # must NOT appear (note: the string 'blob-ssr-content' may appear in JS code).
387 assert 'id="blob-ssr-content"' not in response.text
388 # JS guard variable must be set to false so the JS fallback runs
389 assert "ssrBlobRendered: false" in response.text