import json import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS_PATH = ROOT / "static" / "sessions.js" SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") NODE = shutil.which("node") def _extract_js_function(src: str, name: str) -> str: marker = f"function {name}" start = src.find(marker) assert start >= 0, f"{name} not found" brace = src.find("{", start) assert brace > start, f"{name} body not found" depth = 1 i = brace + 1 while depth and i < len(src): if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 i += 1 assert depth == 0, f"{name} body did not close" return src[start:i] def _run_session_search_helper(cases): if NODE is None: pytest.skip("node not on PATH") driver = "\n".join( [ "function _sessionDisplayTitle(s){return String((s&&s.title)||'');}", _extract_js_function(SESSIONS_JS, "_sessionSearchAddIdCandidate"), _extract_js_function(SESSIONS_JS, "_sessionSearchCleanUrlToken"), _extract_js_function(SESSIONS_JS, "_sessionSearchSessionIdCandidates"), _extract_js_function(SESSIONS_JS, "_sessionSearchDirectSessionMatches"), _extract_js_function(SESSIONS_JS, "_sessionSearchDirectAndTitleMatches"), _extract_js_function(SESSIONS_JS, "_sessionSearchMergeMatches"), "const cases = JSON.parse(process.argv[1]);", "const out = cases.map(c => ({candidates: _sessionSearchSessionIdCandidates(c.query), matches: _sessionSearchDirectSessionMatches(c.sessions, c.query).map(s => s.session_id), merged: _sessionSearchMergeMatches(c.sessions, c.query, c.content || []).map(s => s.session_id)}));", "process.stdout.write(JSON.stringify(out));", ] ) result = subprocess.run( [NODE, "-e", driver, json.dumps(cases)], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0, result.stderr return json.loads(result.stdout) def test_session_action_menu_has_copy_link_action(): assert "ICONS.link" in SESSIONS_JS assert "_appendSessionCopyLinkAction(menu, session);" in SESSIONS_JS assert "t('session_copy_link')" in SESSIONS_JS assert "t('session_copy_link_desc')" in SESSIONS_JS def test_session_link_copies_internal_markdown_reference_not_external_url(): assert "function _sessionInternalReferenceForSession" in SESSIONS_JS assert "session://${_sessionMarkdownUrlSid(sid)}" in SESSIONS_JS assert "_copyTextToClipboard(ref)" in SESSIONS_JS assert "function _sessionAbsoluteUrlForSid" not in SESSIONS_JS def test_session_link_markdown_url_encodes_parentheses(): assert "function _sessionMarkdownUrlSid" in SESSIONS_JS assert "encodeURIComponent(String(sid||''))" in SESSIONS_JS assert "replace(/[()]/g" in SESSIONS_JS assert "%28" in SESSIONS_JS assert "%29" in SESSIONS_JS def test_session_link_label_collapses_multiline_titles(): assert ".replace(/\\s+/g,' ')" in SESSIONS_JS def test_copy_link_has_clipboard_fallback(): assert "navigator.clipboard.writeText" in SESSIONS_JS assert "document.execCommand('copy')" in SESSIONS_JS assert "showToast(t('session_link_copied'))" in SESSIONS_JS assert "t('session_link_copy_failed')" in SESSIONS_JS def test_read_only_sessions_can_still_open_actions_for_copy_link(): start = SESSIONS_JS.index("function _openSessionActionMenu(session, anchorEl){") end = SESSIONS_JS.index("document.addEventListener('click'", start) open_menu_block = SESSIONS_JS[start:end] assert "Read-only imported sessions cannot be modified" not in open_menu_block assert "const isReadOnly = _isReadOnlySession(session);" in open_menu_block # Read-only sessions still get a usable menu: Copy link + Export as HTML # (both non-mutating) are offered, then the menu mounts and returns early # before any mutating action is appended. assert "if(isReadOnly){\n _appendSessionExportHtmlAction(menu, session);\n _mountSessionActionMenu(menu, session, anchorEl);\n return;\n }" in open_menu_block def test_copy_link_i18n_keys_have_english_and_german_labels(): for key in [ "session_copy_link", "session_copy_link_desc", "session_link_copied", "session_link_copy_failed", ]: assert I18N_JS.count(key) >= 2, f"{key} should be defined in English and German" assert "Copy conversation link" in I18N_JS assert "Unterhaltungslink kopieren" in I18N_JS def test_rendered_session_reference_is_internal_link(): assert "session:\\/\\/" in UI_JS assert "function _markdownAnchor" in UI_JS assert "class=\"session-link\"" in UI_JS assert "const sessionLink=e.target.closest('a.session-link[href]');" in UI_JS assert "loadSession(decodeURIComponent(m[1]))" in UI_JS def test_streaming_markdown_keeps_session_refs_internal(): assert "session:\\/\\/" in MESSAGES_JS assert "session-link" in MESSAGES_JS assert "_smdLinkHref" in MESSAGES_JS assert "^(file|workspace|session)" in MESSAGES_JS def test_conversation_filter_extracts_session_ids_from_links_and_raw_ids(): sessions = [ {"session_id": "12f0ef3e1a62", "title": "Target"}, {"session_id": "abc(def)", "title": "Paren"}, {"session_id": "other", "title": "Other"}, ] cases = [ {"query": "12f0ef3e1a62", "sessions": sessions}, {"query": "session://12f0ef3e1a62", "sessions": sessions}, {"query": "https://example.test/session/12f0ef3e1a62", "sessions": sessions}, {"query": "https://example.test/session/12f0ef3e1a62?foo=bar#chat", "sessions": sessions}, {"query": "/session/12f0ef3e1a62", "sessions": sessions}, {"query": "see https://example.test/session/12f0ef3e1a62).", "sessions": sessions}, {"query": "[Target](session://12f0ef3e1a62)", "sessions": sessions}, {"query": "[Target](https://example.test/session/12f0ef3e1a62)", "sessions": sessions}, {"query": "?session_id=12f0ef3e1a62", "sessions": sessions}, {"query": "https://example.test/session/abc%28def%29", "sessions": sessions}, {"query": "session://abc%28def%29", "sessions": sessions}, ] out = _run_session_search_helper(cases) assert [row["matches"] for row in out] == [ ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["12f0ef3e1a62"], ["abc(def)"], ["abc(def)"], ] def test_conversation_filter_merges_direct_title_and_content_matches_without_dropping_posted_ids(): sessions = [ {"session_id": "target-123", "title": "Unrelated target title"}, {"session_id": "title-hit", "title": "target-123 mentioned in title"}, {"session_id": "other", "title": "Other"}, ] content = [ {"session_id": "target-123", "match_type": "content"}, {"session_id": "posted-elsewhere", "match_type": "content"}, {"session_id": "ignored-non-content", "match_type": "title"}, ] out = _run_session_search_helper([ {"query": "target-123", "sessions": sessions, "content": content}, ])[0] assert out["merged"] == ["target-123", "title-hit", "posted-elsewhere"] def test_conversation_filter_keeps_content_search_results_when_query_is_session_id(): assert "function _sessionSearchMergeMatches" in SESSIONS_JS assert "function _sessionSearchDirectAndTitleMatches" in SESSIONS_JS assert "const sidebarRows=_sessionRowsWithActiveEphemeralSession(_allSessions);" in SESSIONS_JS assert "const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);" in SESSIONS_JS assert "const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);" in SESSIONS_JS assert "const directAndTitleMatches=_sessionSearchDirectAndTitleMatches(_allSessions,currentQ);" in SESSIONS_JS assert "const directOrTitleIds=new Set(directAndTitleMatches.map(s=>s.session_id));" in SESSIONS_JS assert "!directOrTitleIds.has(s.session_id)" in SESSIONS_JS assert "api(`/api/sessions/search?q=${encodeURIComponent(requestedQ)}&content=1&depth=5`)" in SESSIONS_JS