test_branch_endpoint_consults_foreign_session_guard_on_missing_sidecar grepped the inline handler block for _claim_or_synthesize_cli_session + not_claimable + 403 — but #5449 extracted that logic into the _load_branch_source_or_refuse helper, leaving only a dead comment carrying the old text to satisfy the grep. Point the test at the helper so it asserts real post-refactor structure instead of a comment.
624 lines
27 KiB
Python
624 lines
27 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 json
|
|
import io
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
import api.routes as routes
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
COMMANDS_JS = ROOT / "static" / "commands.js"
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _read(path: str) -> str:
|
|
return Path(path).read_text(encoding="utf-8")
|
|
|
|
|
|
def _extract_async_function(source: str, name: str) -> str:
|
|
start = source.find(f"async function {name}(")
|
|
assert start != -1, f"Could not find async function {name}"
|
|
brace = source.find("{", start)
|
|
assert brace != -1, f"Could not find opening brace for {name}"
|
|
depth = 0
|
|
for idx in range(brace, len(source)):
|
|
ch = source[idx]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source[start:idx + 1]
|
|
pytest.fail(f"Could not extract complete function body for {name}")
|
|
|
|
|
|
def _run_node(script: str) -> str:
|
|
if NODE is None:
|
|
pytest.skip("node not on PATH")
|
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as handle:
|
|
handle.write(script)
|
|
script_path = handle.name
|
|
try:
|
|
proc = subprocess.run(
|
|
[NODE, script_path],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return proc.stdout.strip()
|
|
finally:
|
|
Path(script_path).unlink(missing_ok=True)
|
|
|
|
|
|
def _commands_harness(body: str) -> str:
|
|
source = COMMANDS_JS.read_text(encoding="utf-8")
|
|
cmd_branch = _extract_async_function(source, "cmdBranch")
|
|
fork_from = _extract_async_function(source, "forkFromMessage")
|
|
return _run_node(
|
|
"\n".join([
|
|
"const calls = [];",
|
|
"const toasts = [];",
|
|
"let ensureCalls = 0;",
|
|
"let loadedSessions = [];",
|
|
"let renderCalls = 0;",
|
|
"let _oldestIdx = 0;",
|
|
"let S = { session: null, busy: false };",
|
|
"const t = (key) => ({",
|
|
" no_active_session: 'No active session',",
|
|
" branch_forked: 'Forked into new session',",
|
|
" branch_failed: 'Fork failed: ',",
|
|
"}[key] || key);",
|
|
"const showToast = (...args) => { toasts.push(args); };",
|
|
"const api = async (url, opts) => {",
|
|
" calls.push({ url, body: JSON.parse(opts.body) });",
|
|
" return { session_id: 'forked-session' };",
|
|
"};",
|
|
"const loadSession = async (sid) => { loadedSessions.push(sid); };",
|
|
"const renderSessionList = async () => { renderCalls += 1; };",
|
|
"const _ensureAllMessagesLoaded = async () => { ensureCalls += 1; };",
|
|
"function _isReadOnlySession(session) {",
|
|
" return !!(session && (session.read_only || session.is_read_only));",
|
|
"}",
|
|
cmd_branch,
|
|
fork_from,
|
|
"(async () => {",
|
|
body,
|
|
"})().catch((err) => {",
|
|
" console.error(err && err.stack ? err.stack : String(err));",
|
|
" process.exit(1);",
|
|
"});",
|
|
])
|
|
)
|
|
|
|
|
|
# ── Backend ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.headers = {"Content-Type": "application/json", "Content-Length": "1"}
|
|
self.rfile = io.BytesIO(b"")
|
|
self.wfile = io.BytesIO()
|
|
self.command = "POST"
|
|
self.path = "/api/session/branch"
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.headers[key] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def _capture_route(monkeypatch):
|
|
cap = {}
|
|
|
|
def _bad(_handler, msg, code=400):
|
|
cap["bad"] = (msg, code)
|
|
return True
|
|
|
|
def _j(_handler, obj, *_, **kwargs):
|
|
cap["ok"] = obj
|
|
cap["status"] = kwargs.get("status", 200)
|
|
return True
|
|
|
|
monkeypatch.setattr(routes, "bad", _bad)
|
|
monkeypatch.setattr(routes, "j", _j)
|
|
return cap
|
|
|
|
def test_branch_endpoint_exists():
|
|
"""Verify the POST /api/session/branch route handler exists."""
|
|
src = _read('api/routes.py')
|
|
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."""
|
|
src = _read('api/routes.py')
|
|
# 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_consults_foreign_session_guard_on_missing_sidecar():
|
|
"""Missing sidecars should classify foreign read-only sessions before 404ing.
|
|
|
|
The classification logic was extracted from the inline handler into the
|
|
``_load_branch_source_or_refuse`` helper (#5449). Assert the handler
|
|
delegates to it, and that the helper carries the real not_claimable→403
|
|
provenance logic — not a stale inline copy.
|
|
"""
|
|
src = _read('api/routes.py')
|
|
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 '_load_branch_source_or_refuse(handler, body["session_id"])' in block, \
|
|
"Branch handler should delegate source-load/refusal to the shared helper"
|
|
|
|
# The helper itself must carry the foreign-session classification + 403.
|
|
helper_match = re.search(
|
|
r'def _load_branch_source_or_refuse\(.*?\)(.*?)(?=\ndef )',
|
|
src, re.DOTALL
|
|
)
|
|
assert helper_match, "Could not find _load_branch_source_or_refuse helper"
|
|
helper = helper_match.group(1)
|
|
assert '_claim_or_synthesize_cli_session(sid)' in helper, \
|
|
"Helper should classify missing-sidecar foreign sessions before returning"
|
|
assert 'if _reason == "not_claimable":' in helper, \
|
|
"Helper should branch on not_claimable foreign ownership"
|
|
assert 'return bad(handler, "Read-only sessions cannot be branched from WebUI", 403)' in helper \
|
|
or ('bad(handler, "Read-only sessions cannot be branched from WebUI", 403)' in helper
|
|
and 'return None' in helper), \
|
|
"Helper should return a provenance-correct 403 for read-only foreign sessions"
|
|
|
|
|
|
def test_branch_endpoint_returns_new_session_id():
|
|
"""Verify the branch endpoint returns session_id and title."""
|
|
src = _read('api/routes.py')
|
|
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."""
|
|
src = _read('api/routes.py')
|
|
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."""
|
|
src = _read('api/routes.py')
|
|
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():
|
|
"""Fork sessions are not collapsed into compression-lineage; guard must remain in _sessionLineageKey."""
|
|
src = _read('static/sessions.js')
|
|
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, \
|
|
"Fork guard must remain in _sessionLineageKey to prevent compression-lineage merging"
|
|
assert block.index("if(s.session_source==='fork') return null;") < block.index('return s.parent_session_id || null')
|
|
|
|
|
|
def test_branch_fork_sessions_nest_under_parent():
|
|
"""Forks with a resolvable in-list parent are subgrouped via _isForkWithResolvableParent
|
|
and fed into _attachChildSessionsToSidebarRows, not rendered as flat top-level rows."""
|
|
src = _read('static/sessions.js')
|
|
# Helper must exist
|
|
assert 'function _isForkWithResolvableParent(' in src, \
|
|
"Missing _isForkWithResolvableParent helper"
|
|
# _attachChildSessionsToSidebarRows must check for fork children
|
|
fn = re.search(r'function _attachChildSessionsToSidebarRows\(.*?\n\}', src, re.DOTALL)
|
|
assert fn, "Could not find _attachChildSessionsToSidebarRows"
|
|
block = fn.group(0)
|
|
assert '_isForkWithResolvableParent' in block, \
|
|
"_attachChildSessionsToSidebarRows must route fork children via _isForkWithResolvableParent"
|
|
# _resolveSessionIdFromSidebarLineage must no longer skip fork rows wholesale
|
|
resolve_fn = re.search(
|
|
r'function _resolveSessionIdFromSidebarLineage\(.*?\n\}', src, re.DOTALL)
|
|
assert resolve_fn, "Could not find _resolveSessionIdFromSidebarLineage"
|
|
resolve_block = resolve_fn.group(0)
|
|
assert "row.session_source==='fork'" not in resolve_block, \
|
|
"_resolveSessionIdFromSidebarLineage must not skip fork rows; they may now be active nested children"
|
|
assert "!_isChildSession(s)&&((s&&s.pinned)||!_isForkWithResolvableParent(s, sessionIdsInList))" in block, \
|
|
"Only unpinned resolvable fork rows should be filtered out of the top-level rows array"
|
|
|
|
|
|
def test_branch_nested_fork_rows_keep_session_actions():
|
|
"""Nested fork rows should keep the standard session action menu path."""
|
|
src = _read('static/sessions.js')
|
|
assert 'session-child-session-fork' in src, \
|
|
"Missing fork-specific nested child row path"
|
|
assert '_openSessionActionMenu(child, menuBtn)' in src, \
|
|
"Nested fork rows should route the standard session action menu"
|
|
assert 'row._startRename=_buildSessionRenameStarter(child, mainBtn' in src, \
|
|
"Nested fork rows should expose the same rename entry point as top-level rows"
|
|
|
|
|
|
def test_branch_nested_fork_search_results_auto_expand():
|
|
"""Nested fork hits should stay visible while sidebar search is active."""
|
|
src = _read('static/sessions.js')
|
|
assert "(_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw)" in src, \
|
|
"Search-active fork matches should auto-expand their nested child group"
|
|
|
|
|
|
def test_branch_nested_fork_rows_render_their_own_state_indicator():
|
|
"""Expanded fork rows should keep unread/streaming/attention affordances."""
|
|
src = _read('static/sessions.js')
|
|
css = _read('static/style.css')
|
|
assert "session-state-indicator session-child-session-state" in src, \
|
|
"Nested fork rows should render a per-row state indicator"
|
|
assert "session-child-session-fork.streaming" in css, \
|
|
"Nested fork rows should expose row-level streaming styling"
|
|
|
|
|
|
def test_branch_keep_count_support():
|
|
"""Verify the branch endpoint supports keep_count parameter."""
|
|
src = _read('api/routes.py')
|
|
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)'."""
|
|
src = _read('api/routes.py')
|
|
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)'"
|
|
|
|
|
|
def test_branch_route_rejects_not_claimable_foreign_sessions_with_403(monkeypatch):
|
|
"""Direct or stale branch POSTs for read-only foreign sessions must refuse clearly."""
|
|
handler = _FakeHandler()
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "cron-1"})
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda _sid, metadata_only=False: (_ for _ in ()).throw(KeyError("Session not found")),
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_claim_or_synthesize_cli_session",
|
|
lambda _sid: (object(), "not_claimable"),
|
|
)
|
|
cap = _capture_route(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/branch"))
|
|
assert cap["bad"] == ("Read-only sessions cannot be branched from WebUI", 403)
|
|
|
|
|
|
def test_branch_route_keeps_404_for_truly_missing_sessions(monkeypatch):
|
|
"""Only real foreign read-only sessions should switch from 404 to 400."""
|
|
handler = _FakeHandler()
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "ghost-1"})
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda _sid, metadata_only=False: (_ for _ in ()).throw(KeyError("Session not found")),
|
|
)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_claim_or_synthesize_cli_session",
|
|
lambda _sid: (None, "no_foreign_state"),
|
|
)
|
|
cap = _capture_route(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/branch"))
|
|
assert cap["bad"] == ("Session not found", 404)
|
|
|
|
|
|
# ── Session model ──────────────────────────────────────────────────────────────
|
|
|
|
def test_session_model_parent_session_id():
|
|
"""Verify Session model supports parent_session_id."""
|
|
src = _read('api/models.py')
|
|
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."""
|
|
src = _read('api/models.py')
|
|
# 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."""
|
|
src = _read('api/models.py')
|
|
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."""
|
|
src = _read('static/commands.js')
|
|
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."""
|
|
src = _read('static/commands.js')
|
|
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."""
|
|
src = _read('static/commands.js')
|
|
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."""
|
|
src = _read('static/commands.js')
|
|
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."""
|
|
src = _read('static/commands.js')
|
|
assert 'async function forkFromMessage(' in src, \
|
|
"forkFromMessage should be defined"
|
|
|
|
|
|
def test_forkFromMessage_passes_keep_count():
|
|
"""Verify forkFromMessage passes keep_count to the endpoint."""
|
|
src = _read('static/commands.js')
|
|
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."""
|
|
src = _read('static/ui.js')
|
|
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."""
|
|
src = _read('static/ui.js')
|
|
# The footHtml template should include forkBtn
|
|
assert '${forkBtn}' in src, \
|
|
"forkBtn should be included in message actions template"
|
|
|
|
|
|
def test_fork_button_is_hidden_for_read_only_sessions():
|
|
"""Read-only sessions should not render the message-level fork affordance."""
|
|
src = _read('static/ui.js')
|
|
assert "const readOnlySession=typeof _isReadOnlySession==='function'" in src, \
|
|
"ui.js should derive a read-only session flag from the shared helper"
|
|
assert "const forkBtn = readOnlySession ? '' :" in src, \
|
|
"fork button should be suppressed when the active session is read-only"
|
|
|
|
|
|
def test_cmdBranch_rejects_read_only_sessions_without_posting():
|
|
"""The /branch command must not POST for read-only sessions."""
|
|
result = _commands_harness(
|
|
"S.session = { session_id: 'cron-1', read_only: true };\n"
|
|
"await cmdBranch('');\n"
|
|
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
|
|
)
|
|
payload = json.loads(result)
|
|
assert payload["calls"] == [], "read-only /branch should not POST /api/session/branch"
|
|
assert payload["ensureCalls"] == 0, "cmdBranch should not trigger message loading"
|
|
assert payload["loadedSessions"] == [], "read-only /branch should not switch sessions"
|
|
assert payload["renderCalls"] == 0, "read-only /branch should not refresh the session list"
|
|
assert payload["toasts"], "read-only /branch should surface a toast"
|
|
assert payload["toasts"][0][0] == "Read-only sessions cannot be forked."
|
|
|
|
|
|
def test_forkFromMessage_rejects_read_only_sessions_without_loading_or_posting():
|
|
"""Read-only message forks must stop before the load/post path."""
|
|
result = _commands_harness(
|
|
"S.session = { session_id: 'cron-1', read_only: true };\n"
|
|
"await forkFromMessage(1);\n"
|
|
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
|
|
)
|
|
payload = json.loads(result)
|
|
assert payload["calls"] == [], "read-only forkFromMessage should not POST /api/session/branch"
|
|
assert payload["ensureCalls"] == 0, "read-only forkFromMessage should return before loading messages"
|
|
assert payload["loadedSessions"] == [], "read-only forkFromMessage should not switch sessions"
|
|
assert payload["renderCalls"] == 0, "read-only forkFromMessage should not refresh the session list"
|
|
assert payload["toasts"], "read-only forkFromMessage should surface a toast"
|
|
assert payload["toasts"][0][0] == "Read-only sessions cannot be forked."
|
|
|
|
|
|
def test_forkFromMessage_preserves_absolute_keep_count_for_writable_sessions():
|
|
"""The read-only guard must not break the existing absolute keep_count fix."""
|
|
result = _commands_harness(
|
|
"S.session = { session_id: 'webui-1', read_only: false };\n"
|
|
"_oldestIdx = 5;\n"
|
|
"await forkFromMessage(3);\n"
|
|
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
|
|
)
|
|
payload = json.loads(result)
|
|
assert len(payload["calls"]) == 1, "writable forkFromMessage should still POST once"
|
|
call = payload["calls"][0]
|
|
assert call["url"] == "/api/session/branch"
|
|
assert call["body"]["session_id"] == "webui-1"
|
|
assert call["body"]["keep_count"] == 8, "keep_count should remain absolute across the guard"
|
|
assert payload["ensureCalls"] == 2, "writable forkFromMessage should preserve both message-load calls"
|
|
assert payload["loadedSessions"] == ["forked-session"]
|
|
assert payload["renderCalls"] == 1
|
|
|
|
|
|
# ── Frontend: sidebar parent indicator ────────────────────────────────────────
|
|
|
|
def test_sidebar_parent_indicator():
|
|
"""Verify parent session indicator is rendered in session list."""
|
|
src = _read('static/sessions.js')
|
|
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."""
|
|
src = _read('static/sessions.js')
|
|
# 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."""
|
|
src = _read('static/sessions.js')
|
|
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."""
|
|
src = _read('static/style.css')
|
|
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."""
|
|
src = _read('static/i18n.js')
|
|
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."""
|
|
src = _read('static/icons.js')
|
|
assert "'git-branch'" in src, \
|
|
"git-branch icon should be defined in LI_PATHS"
|