mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""Tests for issue #465 — session branching (/branch).
|
|
|
|
Verifies:
|
|
1. Backend endpoint POST /api/session/branch exists in routes.py
|
|
2. Session model supports parent_session_id field
|
|
3. Frontend /branch slash command is registered
|
|
4. forkFromMessage function exists in commands.js
|
|
5. Fork button (git-branch icon) is rendered in ui.js message actions
|
|
6. Parent session indicator uses a subtle git-branch icon in sessions.js sidebar
|
|
7. i18n keys exist for all branch-related strings
|
|
8. git-branch icon exists in icons.js
|
|
"""
|
|
import re
|
|
|
|
|
|
# ── Backend ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_branch_endpoint_exists():
|
|
"""Verify the POST /api/session/branch route handler exists."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
assert '"POST /api/session/branch"' in src or '"/api/session/branch"' in src, \
|
|
"Missing /api/session/branch route"
|
|
|
|
|
|
def test_branch_endpoint_validates_session_id():
|
|
"""Verify the branch endpoint requires session_id."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
# Find the branch block
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match, "Could not find /api/session/branch handler block"
|
|
block = branch_match.group(1)
|
|
assert 'require(body, "session_id")' in block, \
|
|
"Branch handler should validate session_id"
|
|
|
|
|
|
def test_branch_endpoint_returns_new_session_id():
|
|
"""Verify the branch endpoint returns session_id and title."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match
|
|
block = branch_match.group(1)
|
|
assert '"session_id"' in block, "Branch handler should return session_id"
|
|
assert '"title"' in block, "Branch handler should return title"
|
|
assert '"parent_session_id"' in block, \
|
|
"Branch handler should return parent_session_id"
|
|
|
|
|
|
def test_branch_creates_session_with_parent():
|
|
"""Verify the branch creates a Session with parent_session_id set."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match
|
|
block = branch_match.group(1)
|
|
assert 'parent_session_id=source.session_id' in block, \
|
|
"Branch handler should set parent_session_id to source session"
|
|
|
|
|
|
def test_branch_marks_explicit_forks_as_fork_sessions():
|
|
"""Explicit branches must not be mistaken for compression lineage rows."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match
|
|
block = branch_match.group(1)
|
|
assert 'session_source="fork"' in block, \
|
|
"Branch handler should mark explicit forks with session_source='fork'"
|
|
|
|
|
|
def test_branch_fork_sessions_do_not_collapse_into_parent_lineage():
|
|
"""Forks remain selectable rows even if their parent is not in the current list."""
|
|
with open('static/sessions.js') as f:
|
|
src = f.read()
|
|
fn = re.search(r'function _sessionLineageKey\(.*?\n\}', src, re.DOTALL)
|
|
assert fn, "Could not find _sessionLineageKey"
|
|
block = fn.group(0)
|
|
assert "if(s.session_source==='fork') return null;" in block, \
|
|
"Explicit fork sessions should not collapse via parent_session_id"
|
|
assert block.index("if(s.session_source==='fork') return null;") < block.index('return s.parent_session_id || null')
|
|
|
|
|
|
def test_branch_keep_count_support():
|
|
"""Verify the branch endpoint supports keep_count parameter."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match
|
|
block = branch_match.group(1)
|
|
assert 'keep_count' in block, "Branch handler should support keep_count"
|
|
assert 'forked_messages = source_messages[:keep_count]' in block, \
|
|
"Branch handler should slice messages by keep_count"
|
|
|
|
|
|
def test_branch_auto_title():
|
|
"""Verify fork title defaults to '<original> (fork)'."""
|
|
with open('api/routes.py') as f:
|
|
src = f.read()
|
|
branch_match = re.search(
|
|
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
|
|
src, re.DOTALL
|
|
)
|
|
assert branch_match
|
|
block = branch_match.group(1)
|
|
assert '(fork)' in block, "Branch handler should auto-title as '(fork)'"
|
|
|
|
|
|
# ── Session model ──────────────────────────────────────────────────────────────
|
|
|
|
def test_session_model_parent_session_id():
|
|
"""Verify Session model supports parent_session_id."""
|
|
with open('api/models.py') as f:
|
|
src = f.read()
|
|
assert 'parent_session_id' in src, "Session model should have parent_session_id"
|
|
# Check __init__ parameter
|
|
assert 'parent_session_id: str=None' in src, \
|
|
"Session.__init__ should accept parent_session_id parameter"
|
|
# Check it's set on self
|
|
assert 'self.parent_session_id = parent_session_id' in src, \
|
|
"Session.__init__ should assign parent_session_id"
|
|
|
|
|
|
def test_session_compact_includes_parent():
|
|
"""Verify compact() includes parent_session_id."""
|
|
with open('api/models.py') as f:
|
|
src = f.read()
|
|
# Find the compact method and scan its full body for parent_session_id.
|
|
# PR #1591 (May 2026) added a has_pending_user_message recompute block at
|
|
# the top of compact() which pushed the parent_session_id field beyond a
|
|
# 1500-char window — widen the scan to 3000 chars to cover the full
|
|
# return-dict body without re-tightening every time compact() grows.
|
|
compact_def_match = re.search(r"def compact\(self", src)
|
|
assert compact_def_match, "Could not find compact() method"
|
|
snippet = src[compact_def_match.start():compact_def_match.start() + 3000]
|
|
assert "'parent_session_id'" in snippet, \
|
|
"compact() should include parent_session_id"
|
|
|
|
|
|
def test_session_metadata_fields_includes_parent():
|
|
"""Verify parent_session_id is in METADATA_FIELDS for persistence."""
|
|
with open('api/models.py') as f:
|
|
src = f.read()
|
|
assert "'parent_session_id'" in src, \
|
|
"METADATA_FIELDS should include parent_session_id"
|
|
|
|
|
|
# ── Frontend: slash command ────────────────────────────────────────────────────
|
|
|
|
def test_branch_slash_command_registered():
|
|
"""Verify /branch is registered as a slash command."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
assert "name:'branch'" in src, "/branch should be registered as a command"
|
|
assert 'cmdBranch' in src, "cmdBranch handler should be defined"
|
|
|
|
|
|
def test_cmdBranch_function_exists():
|
|
"""Verify cmdBranch function is defined."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
assert 'async function cmdBranch(' in src, \
|
|
"cmdBranch should be an async function"
|
|
|
|
|
|
def test_cmdBranch_calls_branch_endpoint():
|
|
"""Verify cmdBranch calls the /api/session/branch endpoint."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
|
|
assert branch_fn, "Could not find cmdBranch function"
|
|
block = branch_fn.group(0)
|
|
assert "'/api/session/branch'" in block, \
|
|
"cmdBranch should call /api/session/branch"
|
|
|
|
|
|
def test_cmdBranch_switches_session():
|
|
"""Verify cmdBranch calls loadSession after branching."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
|
|
assert branch_fn
|
|
block = branch_fn.group(0)
|
|
assert 'loadSession(' in block, \
|
|
"cmdBranch should switch to the new session via loadSession"
|
|
|
|
|
|
# ── Frontend: forkFromMessage ─────────────────────────────────────────────────
|
|
|
|
def test_forkFromMessage_function_exists():
|
|
"""Verify forkFromMessage function exists."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
assert 'async function forkFromMessage(' in src, \
|
|
"forkFromMessage should be defined"
|
|
|
|
|
|
def test_forkFromMessage_passes_keep_count():
|
|
"""Verify forkFromMessage passes keep_count to the endpoint."""
|
|
with open('static/commands.js') as f:
|
|
src = f.read()
|
|
fn = re.search(r'async function forkFromMessage\(.*?\n\}', src, re.DOTALL)
|
|
assert fn
|
|
block = fn.group(0)
|
|
assert 'keep_count' in block, \
|
|
"forkFromMessage should pass keep_count to /api/session/branch"
|
|
|
|
|
|
# ── Frontend: fork button in messages ──────────────────────────────────────────
|
|
|
|
def test_fork_button_rendered_in_ui():
|
|
"""Verify fork button is rendered in message actions."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert "forkBtn" in src, "forkBtn variable should exist in ui.js"
|
|
assert "fork_from_here" in src, \
|
|
"fork_from_here i18n key should be referenced for tooltip"
|
|
assert "forkFromMessage(" in src, \
|
|
"forkFromMessage should be called from the button"
|
|
|
|
|
|
def test_fork_button_in_message_actions():
|
|
"""Verify fork button is included in the msg-actions span."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
# The footHtml template should include forkBtn
|
|
assert '${forkBtn}' in src, \
|
|
"forkBtn should be included in message actions template"
|
|
|
|
|
|
# ── Frontend: sidebar parent indicator ────────────────────────────────────────
|
|
|
|
def test_sidebar_parent_indicator():
|
|
"""Verify parent session indicator is rendered in session list."""
|
|
with open('static/sessions.js') as f:
|
|
src = f.read()
|
|
assert 'parent_session_id' in src, \
|
|
"sessions.js should check parent_session_id"
|
|
assert 'session-branch-indicator' in src, \
|
|
"Should have session-branch-indicator class"
|
|
assert "li('git-branch',12)" in src, \
|
|
"Sidebar parent indicator should use the git-branch icon"
|
|
assert '\\u2442' not in src, \
|
|
"Sidebar parent indicator should not use the opaque OCR double-backslash glyph"
|
|
|
|
|
|
def test_parent_indicator_not_clickable():
|
|
"""Verify parent indicator is informational, not hidden navigation."""
|
|
with open('static/sessions.js') as f:
|
|
src = f.read()
|
|
# Find the parent indicator block
|
|
parent_block = re.search(
|
|
r'branch-indicator[\s\S]*?parent_session_id[\s\S]*?titleRow\.appendChild',
|
|
src
|
|
)
|
|
assert parent_block, "Could not find parent indicator block"
|
|
block = parent_block.group(0)
|
|
assert 'loadSession(' not in block, \
|
|
"Parent indicator should not navigate to the parent from the sidebar"
|
|
assert 'onclick' not in block, \
|
|
"Parent indicator should not register a hidden click target"
|
|
|
|
|
|
def test_parent_indicator_tooltip_uses_parent_title_fallback():
|
|
"""Tooltip should prefer a parent title and only fall back to a short id."""
|
|
with open('static/sessions.js') as f:
|
|
src = f.read()
|
|
assert 'function _sessionTitleForForkParent' in src, \
|
|
"sessions.js should resolve a user-facing parent title"
|
|
assert 'function _truncatedSessionId' in src, \
|
|
"sessions.js should fall back to a truncated id, not raw session_id"
|
|
assert "_sessionTitleForForkParent(s.parent_session_id)||_truncatedSessionId(s.parent_session_id)" in src, \
|
|
"parent indicator tooltip must prefer title and fall back to truncated id"
|
|
|
|
|
|
def test_parent_indicator_hover_only_style():
|
|
"""The sidebar lineage indicator should be visually subdued until row hover/focus."""
|
|
with open('static/style.css') as f:
|
|
src = f.read()
|
|
assert '.session-branch-indicator' in src, \
|
|
"Missing session branch indicator CSS"
|
|
assert 'opacity:.35' in src, \
|
|
"Fork lineage indicator should be subdued at rest"
|
|
assert '.session-item:hover .session-branch-indicator' in src, \
|
|
"Fork lineage indicator should become visible on row hover"
|
|
|
|
|
|
# ── Frontend: i18n keys ────────────────────────────────────────────────────────
|
|
|
|
def test_i18n_branch_keys():
|
|
"""Verify all branch-related i18n keys exist in English locale."""
|
|
with open('static/i18n.js') as f:
|
|
src = f.read()
|
|
required_keys = [
|
|
'cmd_branch',
|
|
'cmd_branch_usage',
|
|
'branch_forked',
|
|
'branch_failed',
|
|
'fork_from_here',
|
|
'forked_from',
|
|
]
|
|
for key in required_keys:
|
|
assert f"{key}:" in src or f"{key} :" in src, \
|
|
f"Missing i18n key: {key}"
|
|
|
|
|
|
# ── Frontend: icon ─────────────────────────────────────────────────────────────
|
|
|
|
def test_git_branch_icon_exists():
|
|
"""Verify git-branch icon is defined in icons.js."""
|
|
with open('static/icons.js') as f:
|
|
src = f.read()
|
|
assert "'git-branch'" in src, \
|
|
"git-branch icon should be defined in LI_PATHS"
|