"""
Tests for feat #450: MEDIA: token inline rendering in web UI chat.
Covers:
1. /api/media endpoint: serves local image files by absolute path
2. /api/media endpoint: rejects paths outside allowed roots (path traversal)
3. /api/media endpoint: 404 for non-existent files
4. /api/media endpoint: auth gate when auth is enabled
5. renderMd() MEDIA: stash/restore logic (static JS analysis)
6. /api/media endpoint: integration test via live server (requires 8788)
"""
from __future__ import annotations
import json
import os
import pathlib
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
import urllib.error
import urllib.parse
import urllib.request
from tests._pytest_port import BASE, TEST_STATE_DIR
from tests.conftest import TEST_WORKSPACE
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def _media_fixture_dir() -> pathlib.Path:
# Dot-prefixed so the persistent fixture dir stays OUT of
# /api/workspaces/suggest's default (non-hidden) results — otherwise it
# pollutes the shared TEST_WORKSPACE and breaks sibling tests that assert an
# exact workspace-suggestion set (e.g. test_sprint5
# test_workspace_suggest_hidden_dirs_only_when_requested). Kept under
# TEST_WORKSPACE (a MEDIA_ALLOWED_ROOT) and Windows-portable (no /tmp).
fixture_dir = TEST_WORKSPACE / ".media-fixtures"
fixture_dir.mkdir(parents=True, exist_ok=True)
return fixture_dir
# ── Static analysis: renderMd MEDIA stash ────────────────────────────────────
class TestMediaRenderMdStash(unittest.TestCase):
"""Verify the MEDIA: stash/restore logic exists in ui.js."""
def test_media_stash_defined(self):
self.assertIn("media_stash", UI_JS,
"media_stash array must be defined in renderMd()")
def test_media_token_regex(self):
self.assertIn("MEDIA:", UI_JS,
"MEDIA: token regex must be present in renderMd()")
def test_bare_file_urls_are_stashed_as_media_artifacts(self):
self.assertIn("file:// links for local artifacts", UI_JS)
self.assertIn("file:\\/\\/[^\\s<>", UI_JS)
def test_file_urls_are_rewritten_through_media_endpoint(self):
self.assertIn("new URL(ref)", UI_JS)
self.assertIn("u.pathname", UI_JS)
self.assertIn("api/media?path=", UI_JS)
def test_media_restore_produces_img_tag(self):
self.assertIn("msg-media-img", UI_JS,
"restore pass must produce ")
def test_media_restore_produces_download_link(self):
self.assertIn("msg-media-link", UI_JS,
"restore pass must produce download link for non-image files")
def test_local_image_media_uses_clean_image_with_hover_download(self):
# #3220 redesign: generated local images render as a clean inline image
# (keeping the lightbox-on-click) with a hover/focus-revealed Download
# overlay — matching the ChatGPT/Claude/Gemini pattern — instead of a
# permanent bordered card with always-visible Open/Download buttons.
self.assertIn("localArtifactCard", UI_JS)
self.assertIn("msg-artifact-image", UI_JS)
self.assertIn("msg-artifact-download", UI_JS)
self.assertIn("msg-media-img", UI_JS)
self.assertIn("t('media_download')", UI_JS)
self.assertIn("media_download:", I18N_JS)
# The clean-image redesign drops the permanent card chrome.
self.assertNotIn("msg-artifact-card", UI_JS)
self.assertNotIn("msg-artifact-actions", UI_JS)
self.assertNotIn("downloadUrl=src+(String(src).includes('?')?'&':'?')+'download=1'", UI_JS)
self.assertNotIn("openUrl=src+(String(src).includes('?')?'&':'?')+'inline=1'", UI_JS)
def test_media_api_url_pattern(self):
self.assertIn("api/media?path=", UI_JS,
"renderMd must build api/media?path=... URL for local files")
def test_local_media_api_url_carries_session_id_when_available(self):
self.assertIn("session_id='+encodeURIComponent(mediaSessionId)", UI_JS,
"local MEDIA: image URLs must include session_id so the server can authorize session-referenced artifacts")
def test_local_audio_video_media_tokens_request_inline_streaming(self):
self.assertIn("apiUrl+'&inline=1'", UI_JS,
"MEDIA: audio/video local paths must request inline streaming")
def test_media_stash_uses_null_byte_token(self):
self.assertIn("\\x00D", UI_JS,
"MEDIA stash must use null-byte token (\\x00D) to avoid conflicts")
def test_media_stash_runs_before_fence_stash(self):
media_pos = UI_JS.find("media_stash")
fence_pos = UI_JS.find("fence_stash")
self.assertGreater(fence_pos, media_pos,
"media_stash must be defined before fence_stash in renderMd()")
def test_image_extension_regex_covers_common_types(self):
# The JS source has these extensions in a regex like /\.png|jpg|.../i
# Check for the extension strings (without the dot, which may be escaped as \.)
for ext in ["png", "jpg", "jpeg", "gif", "webp"]:
self.assertIn(ext, UI_JS,
f"Image extension {ext} must be in the MEDIA img-check regex")
def test_http_url_media_rendered_as_img(self):
# renderMd should treat MEDIA:https://... as an
# In the JS source, the regex is /^https?:\/\//i (escaped)
self.assertTrue(
"https?:" in UI_JS or "http" in UI_JS,
"MEDIA: restore must handle HTTPS URLs",
)
def test_zoom_toggle_on_click(self):
# PR #1135: CSS class toggle replaced by proper lightbox overlay
self.assertIn("_openImgLightbox", UI_JS,
"Clicking the image must open lightbox overlay (_openImgLightbox)")
# ── Static analysis: CSS ──────────────────────────────────────────────────────
class TestMediaCSS(unittest.TestCase):
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def test_msg_media_img_class_defined(self):
self.assertIn(".msg-media-img", self.CSS)
def test_msg_media_img_max_width(self):
# PR #1135: resting thumbnail is 120x90px (fixed size); no max-width needed.
# Lightbox shows full-size. Check width is set instead.
idx = self.CSS.find(".msg-media-img{")
self.assertGreater(idx, 0)
rule = self.CSS[idx:idx+200]
self.assertIn("width:120px", rule, "Thumbnail must have fixed 120px width")
def test_msg_media_img_full_class_defined(self):
# PR #1135: .msg-media-img--full removed; lightbox replaces inline zoom.
self.assertIn(".img-lightbox", self.CSS,
"Full-size toggle class must exist for zoom-on-click")
def test_msg_media_link_class_defined(self):
self.assertIn(
".msg-media-link",
self.CSS,
"Download link style must be defined for non-image media",
)
def test_generated_artifact_image_css_defined(self):
# #3220 redesign: clean image + hover-revealed download overlay.
for cls in [
".msg-artifact-image",
".msg-artifact-download",
]:
self.assertIn(cls, self.CSS)
# Hover/focus reveals the download button (hidden by default).
self.assertIn(".msg-artifact-image:hover .msg-artifact-download", self.CSS)
# The old permanent-card classes are gone.
self.assertNotIn(".msg-artifact-card", self.CSS)
self.assertNotIn(".msg-artifact-action", self.CSS)
class TestInlineAudioVideoEditor(unittest.TestCase):
"""Static checks for inline audio/video preview controls in chat and workspace."""
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
def test_audio_and_video_extension_detection_exists(self):
self.assertIn("_AUDIO_EXTS", UI_JS)
self.assertIn("_VIDEO_EXTS", UI_JS)
for ext in ["mp3", "wav", "m4a", "mp4", "mov", "webm"]:
self.assertIn(ext, UI_JS)
def test_media_player_markup_has_native_controls(self):
self.assertIn("_mediaPlayerHtml", UI_JS)
self.assertIn("