Files
hermes-webui/tests/test_issue5060_boot_composer_preserve.py
T
nesquena-hermesandrodboev 519c394dfe fix(boot): preserve typed composer input during saved-session boot restore (#5069)
On a hard refresh, boot-restore auto-opens the last session from localStorage,
but that load can finish after the user has started typing into the fresh
composer — and the saved-session draft restore clobbered the typed input.
_restoreComposerDraft already had a preserveActiveInput guard, but the boot
path never set it.

boot.js now calls loadSession(saved, {preserveActiveInput:true}); loadSession
threads it into the draft restore as
preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload),
preserving the prior same-session-force-reload behavior. A blank composer still
restores the saved draft (the guard only skips when active input is non-empty
and differs). opts is read via arguments[1] so the call is signature-safe.

The 4 one-line test changes refresh exact-string boot snapshots to the new call
signature; test_issue5060_boot_composer_preserve.py adds behavioral coverage
(2 assertions proven RED on master).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:51:46 +00:00

138 lines
4.2 KiB
Python

"""Regression tests for #5060: preserve typed composer input during boot restore."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _function_block(source: str, marker: str) -> str:
start = source.index(marker)
signature_end = source.index(") {", start)
brace = source.index("{", signature_end)
depth = 1
idx = brace + 1
while depth:
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
idx += 1
return source[start:idx]
def _draft_restore_block() -> str:
start_marker = "// Restore server-persisted composer draft (synced across clients + survives refresh)."
end_marker = "// Clear the in-flight session marker now that this load has completed (#1060)."
start = SESSIONS_JS.index(start_marker)
end = SESSIONS_JS.index(end_marker, start)
return SESSIONS_JS[start:end]
def _run_case(*, initial_text: str, draft: dict | None, opts: dict | None, current_sid, force_reload: bool) -> dict:
if NODE is None:
pytest.skip("node not on PATH")
restore_fn = _function_block(SESSIONS_JS, "function _restoreComposerDraft(draft, targetSid, opts={}) {")
draft_block = _draft_restore_block()
script = f"""
const state = {{
value: {json.dumps(initial_text)},
autoResizeCount: 0,
updateSendBtnCount: 0,
}};
function $() {{
return state;
}}
function autoResize() {{
state.autoResizeCount += 1;
}}
function updateSendBtn() {{
state.updateSendBtnCount += 1;
}}
const S = {{
session: {{
composer_draft: {json.dumps(draft)},
}},
}};
let _loadingSessionId = null;
const sid = 'boot-session';
const currentSid = {json.dumps(current_sid)};
const forceReload = {json.dumps(force_reload)};
const opts = {json.dumps(opts or {})};
{restore_fn}
{draft_block}
process.stdout.write(JSON.stringify({{
value: state.value,
autoResizeCount: state.autoResizeCount,
updateSendBtnCount: state.updateSendBtnCount,
}}));
"""
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, timeout=20)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_boot_restore_preserves_typed_input_against_remote_draft():
"""Boot restore should keep local typed text when the saved draft arrives later."""
data = _run_case(
initial_text="typed locally",
draft={"text": "server draft", "files": []},
opts={"preserveActiveInput": True},
current_sid=None,
force_reload=False,
)
assert data["value"] == "typed locally"
assert data["autoResizeCount"] == 0
assert data["updateSendBtnCount"] == 0
def test_boot_restore_preserves_typed_input_when_saved_draft_is_empty():
"""Boot restore should keep local typed text even when the restored draft is empty."""
data = _run_case(
initial_text="typed locally",
draft={"text": "", "files": []},
opts={"preserveActiveInput": True},
current_sid=None,
force_reload=False,
)
assert data["value"] == "typed locally"
assert data["autoResizeCount"] == 0
assert data["updateSendBtnCount"] == 0
def test_boot_restore_still_populates_empty_composer_from_saved_draft():
"""A blank composer should still take the server draft during boot restore."""
data = _run_case(
initial_text="",
draft={"text": "server draft", "files": []},
opts={"preserveActiveInput": True},
current_sid=None,
force_reload=False,
)
assert data["value"] == "server draft"
assert data["autoResizeCount"] == 1
assert data["updateSendBtnCount"] == 1
def test_cross_session_restore_keeps_existing_draft_semantics():
"""Ordinary cross-session loads should still restore the target session draft."""
data = _run_case(
initial_text="typed locally",
draft={"text": "server draft", "files": []},
opts={},
current_sid="other-session",
force_reload=False,
)
assert data["value"] == "server draft"
assert data["autoResizeCount"] == 1
assert data["updateSendBtnCount"] == 1