from pathlib import Path import re def test_tool_approval_bypasses_polymorphic_send_button_actions(): root = Path(__file__).resolve().parents[1] chat = (root / "static/js/chat.js").read_text(encoding="utf-8") stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8") # chat.js still defers the sealed approval through a synthetic button click. assert "if (sendButton) sendButton.click();" in chat # The capture listener must intercept only that synthetic click and route it # through the chat form submit path, before app.js can reinterpret an empty # composer as New chat or Record voice. assert "if (event.isTrusted) return;" in stream assert "event.stopImmediatePropagation();" in stream assert "chatForm.requestSubmit()" in stream assert "sendButton.dataset.mode = ''" not in stream def test_ask_user_card_has_no_close_button_and_chat_scale_text(): root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") styles = (root / "static/style.css").read_text(encoding="utf-8") assert "closeBtn.className = 'modal-close ask-user-close';" not in renderer assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" not in renderer assert "card.appendChild(head);" not in renderer assert "otherSend.textContent" not in renderer assert "otherSend.innerHTML" in renderer assert 'd="M12 19V5M5 12l7-7 7 7"' in renderer assert "ask-user-card-attached" in renderer assert "has-ask-user-bottom" in renderer assert ".ask-user-head" in styles assert ".ask-user-close" in styles assert "display: none !important;" in styles question_block = styles[styles.index(".ask-user-question {"):styles.index(".ask-user-options {")] card_block = styles[styles.index(".ask-user-card {"):styles.index(".ask-user-card-attached {")] attached_block = styles[styles.index(".ask-user-card-attached {"):styles.index(".ask-user-card-attached::before {")] connector_block = styles[styles.index(".ask-user-card-attached::before {"):styles.index("/* Focused only programmatically", styles.index(".ask-user-card-attached::before {"))] label_start = styles.index(".ask-user-option-label {", styles.index(".ask-user-option-label,")) label_block = styles[label_start:styles.index(".ask-user-option-desc {", label_start)] assert "color: var(--fg);" in question_block assert "max-width: 85%;" in card_block assert "isolation: isolate;" in card_block assert "margin-left: 8px;" in attached_block assert "width: 85%;" in attached_block assert "max-width: 85%;" in attached_block assert "z-index: -1;" in connector_block assert "color: var(--accent, var(--red));" in label_block assert "font-size: 11px;" in styles assert "font-size: 11px !important;" in styles assert "background: var(--ai-bubble-bg, var(--panel));" in styles assert "background: var(--send-btn-bg, var(--red));" in styles assert "background: color-mix(in srgb, var(--fg) 4%, var(--panel));" not in styles assert ".ask-user-other-input" in styles assert ".ask-user-answer" in styles assert ".ask-user-answer-value" in styles assert ".ask-user-card-attached::before" in styles assert ".agent-thread.has-ask-user-bottom::before" in styles def test_scroll_bottom_button_uses_dropdown_caret_glyph(): root = Path(__file__).resolve().parents[1] html = (root / "static/index.html").read_text(encoding="utf-8") styles = (root / "static/style.css").read_text(encoding="utf-8") assert 'class="scroll-nav-caret"' in html assert "▾" in html assert ">▼" not in html assert ".scroll-nav-caret" in styles assert "font-family: Arial, Helvetica, sans-serif;" in styles def test_ask_user_number_shortcuts_reuse_option_click_path(): root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") start = renderer.index("function _handleAskUserShortcut(event)") end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start) shortcut = renderer[start:end] assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut assert "event.repeat" in shortcut assert "event.ctrlKey" in shortcut assert "event.altKey" in shortcut assert "event.metaKey" in shortcut assert "event.shiftKey" in shortcut assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut assert "event.preventDefault();" in shortcut assert "option.click();" in shortcut def test_digit_shortcuts_never_answer_a_tool_approval_card(): """A stray digit must not grant a scope the user did not deliberately pick.""" root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") start = renderer.index("function _handleAskUserShortcut(event)") end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start) shortcut = renderer[start:end] assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut # The renderer has to label the card for that guard to ever fire. assert ( "card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';" in renderer ) def test_ask_user_renderer_accepts_scoped_root_and_submit_callback(): root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer assert "kind: 'answer'" in renderer assert "kind: 'tool_approval'" in renderer assert "function _markAskUserAnswered(card, text)" in renderer assert "function _answerAskUserCards(root, text)" in renderer assert "if (accepted !== false) _markAskUserAnswered(card, text);" in renderer assert "if (accepted !== false) _markAskUserAnswered(card, label);" in renderer assert "scope.querySelectorAll('.ask-user-card:not(.ask-user-answered)')" in renderer assert "if (role === 'user') _answerAskUserCards(box," in renderer assert "if (role === 'user') removeAskUserCards(box);" not in renderer assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer def test_every_changed_approval_module_is_cache_busted_together(): """A stale module here silently reinterprets the approval click. chat.js leaves the composer empty and clicks the polymorphic send button, so a browser that pairs the new chat.js with a cached chatStream.js has no interceptor and lands on the New chat branch instead. The same holds for the compare pane modules, which chatRenderer now shares a keydown listener with. """ root = Path(__file__).resolve().parents[1] sources = [ path.read_text(encoding="utf-8") for path in (root / "static").rglob("*") if path.suffix in {".js", ".html"} ] def versions(module_name): pattern = re.compile(rf"{re.escape(module_name)}\?v=([A-Za-z0-9_-]+)") return [match for source in sources for match in pattern.findall(source)] # Every URL for a stateful module must resolve to one ES-module instance. # Different query strings create distinct modules and duplicate listeners. for module_name in ("chatRenderer.js", "chatStream.js", "chat.js"): found = versions(module_name) assert found, f"missing cache-busted reference for {module_name}" assert len(set(found)) == 1, f"split module graph for {module_name}: {found}" # These shared modules are imported throughout the graph. Keep their URL # canonical and unversioned; mixing a query URL with plain relative imports # creates a second singleton with separate state and listeners. for module_name in ("sessions.js", "ui.js", "memory.js", "markdown.js", "models.js"): assert any(module_name in source for source in sources) assert not versions(module_name), f"split module graph for {module_name}" compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8") compare_vote = (root / "static/js/compare/vote.js").read_text(encoding="utf-8") renderer_version = versions("chatRenderer.js")[0] assert f"chatRenderer.js?v={renderer_version}" in compare_stream assert f"chatRenderer.js?v={renderer_version}" in compare_vote