mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-16 05:02:21 +02:00
170 lines
8.5 KiB
Python
170 lines
8.5 KiB
Python
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 ">▼</button>" 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
|