"""Static-analysis tests for the conversation outline panel (issue #2124).""" from pathlib import Path import re ROOT = Path(__file__).parent.parent STATIC = ROOT / "static" INDEX_HTML = (STATIC / "index.html").read_text(encoding="utf-8") I18N_JS = (STATIC / "i18n.js").read_text(encoding="utf-8") OUTLINE_JS = (STATIC / "outline.js").read_text(encoding="utf-8") STYLE_CSS = (STATIC / "style.css").read_text(encoding="utf-8") BOOT_JS = (STATIC / "boot.js").read_text(encoding="utf-8") PANELS_JS = (STATIC / "panels.js").read_text(encoding="utf-8") CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") # Number of locale blocks in i18n.js: en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr LOCALE_COUNT = 12 def _css_rule_body(selector): rule = re.search(re.escape(selector) + r"\{(?P[^}]*)\}", STYLE_CSS, re.S) assert rule return rule.group("body") def _css_px(rule_body, property_name): match = re.search(rf"{re.escape(property_name)}:(?P\d+)px", rule_body) assert match return int(match.group("value")) def test_outline_panel_html_and_i18n_contract(): """The panel shell, script tag, and locale keys must be present.""" assert 'src="static/outline.js?v=__WEBUI_VERSION__"' in INDEX_HTML assert "outline.js?v=__WEBUI_VERSION__" in INDEX_HTML assert re.search(r']+outline\.js\?v=__WEBUI_VERSION__[^>]+defer', INDEX_HTML) assert re.search(r'id="outlineToggleBtn"[^>]*hidden', INDEX_HTML) assert re.search(r'id="outlinePanelWrapper"[^>]*hidden', INDEX_HTML) assert 'id="outlinePanel"' in INDEX_HTML for key in ( "outline_title:", "outline_empty:", "outline_loading:", "settings_label_conversation_outline:", "settings_desc_conversation_outline:", ): assert I18N_JS.count(key) >= LOCALE_COUNT def test_outline_setting_round_trip_contract(): """The outline preference must default off and use the normal settings path.""" assert '"show_conversation_outline": False' in CONFIG_PY assert '"show_conversation_outline",' in CONFIG_PY assert 'id="settingsShowConversationOutline"' in INDEX_HTML assert "payload.show_conversation_outline=showConversationOutlineCb.checked;" in PANELS_JS assert "settings.show_conversation_outline===true" in PANELS_JS assert "body.show_conversation_outline=showConversationOutline===true;" in PANELS_JS assert "window._showConversationOutline=s.show_conversation_outline===true" in BOOT_JS assert "window._showConversationOutline=false" in BOOT_JS def test_outline_navigation_and_long_session_contract(): """Outline entries must come from session messages and recover off-window targets.""" for marker in ( "_outlineSid", "window.toggleOutlinePanel", "window._outlineJump", "S.messages", "'msg-user-'", "/api/session", "_ensureOutlineMessagesLoaded", "_ensureAllMessagesLoaded()", "_messagesTruncated", "_expandOutlineRenderWindow()", "_messageRenderWindowSize = Math.max(", "renderMessages({ preserveScroll: true })", "if (S.busy || S.activeStreamId) return;", ): assert marker in OUTLINE_JS def test_outline_opt_in_layout_and_render_state_contract(): """The enabled outline must stay desktop-only and avoid stale render states.""" for marker in ( "window._showConversationOutline === true", "toggle.hidden = !enabled", "wrapper.hidden = true", "window.applyConversationOutlinePreference", "matchMedia('(max-width:900px)')", "--outline-workspace-offset", "panel.offsetWidth", "data-workspace-panel", "window._outlineRenderHookPending", "if (!S.messages) {", ): assert marker in OUTLINE_JS assert "#outlineToggleBtn,#outlinePanelWrapper{display:none!important;}" in STYLE_CSS assert "right:calc(var(--outline-workspace-offset, 0px) + 20px)" in STYLE_CSS assert "if (!S.messages || !S.messages.length)" not in OUTLINE_JS before_hooked = OUTLINE_JS.index("window._outlineRenderHooked = true") before_wrapper = OUTLINE_JS.index("window.renderMessages = function") missing_render_block = OUTLINE_JS.split("if (typeof _orig !== 'function')", 1)[1] missing_render_block = missing_render_block.split("window._outlineRenderHooked = true", 1)[0] assert before_hooked < before_wrapper assert "window._outlineRenderHooked = true" not in missing_render_block def test_outline_is_chat_only_and_closes_on_panel_switch(): """The outline is a chat-view affordance: leaving chat must hide the toggle AND close the panel, and returning to chat restores the toggle. (Auto-close on panel switch — review follow-up.)""" # _outlineAllowed() gates on the active panel, not just the setting + width. assert "_currentPanel" in OUTLINE_JS assert "panel === 'chat'" in OUTLINE_JS # Re-evaluated on main-view changes: switchPanel() toggles `showing-` # on
, and the MutationObserver watches that class to re-run the gate. assert "main.main" in OUTLINE_JS assert "attributeFilter: ['class']" in OUTLINE_JS def test_outline_wrapper_hidden_attr_actually_hides(): """The #outlinePanelWrapper id selector sets display:flex, which outranks the UA [hidden]{display:none} rule — so the hidden attribute alone would NOT hide the panel (the close button / auto-close set wrapper.hidden=true). An explicit #outlinePanelWrapper[hidden]{display:none} rule restores the expected behavior.""" assert "#outlinePanelWrapper[hidden]{display:none;}" in STYLE_CSS def test_outline_fab_stacks_with_scroll_controls(): """The outline FAB shares the messages-shell positioning context with the scroll controls, so composer height changes cannot make the controls overlap in the bottom-right corner. (#4381)""" shell_start = INDEX_HTML.index('
') messages_start = INDEX_HTML.index('
', shell_start) shell_controls = INDEX_HTML[shell_start:messages_start] assert 'id="outlineToggleBtn"' in shell_controls assert shell_controls.index('id="scrollToBottomBtn"') < shell_controls.index('id="outlineToggleBtn"') outline_css = _css_rule_body("#outlineToggleBtn") scroll_css = _css_rule_body(".scroll-to-bottom-btn") assert "position:absolute" in outline_css assert "position:fixed" not in outline_css assert _css_px(outline_css, "right") == _css_px(scroll_css, "right") outline_gap = _css_px(outline_css, "bottom") scroll_top = _css_px(scroll_css, "bottom") + _css_px(scroll_css, "height") assert outline_gap > scroll_top