Files
hermes-webui/tests/test_status_command_card.py
T
Thireus ☠ 2d48f4c306 ui: give the renderMessages probes the block helper
Four upstream tests read renderMessages out of ui.js and run a slice of it against a fake segment object. Since the message block goes in through _insertSegmentBlock, three of them stopped at "ReferenceError: _insertSegmentBlock is not defined" and the fourth still pinned the old seg.insertAdjacentHTML line, which took twelve of the fifteen test shards red on this branch and nowhere else.

test_status_command_card.py defines the helper in its own harness, one line that does what the real helper does without a browser. test_compaction_current_summary_fallback.py and test_anchor_fallback_ownership.py evaluate the real helper from ui.js alongside the functions they already evaluate; their DOM shims return no template content, so it takes its insertAdjacentHTML fallback exactly as before. test_sprint49.py pins the new call, and its point is unchanged: the footer still rides in the same block as the body.

A new case in tests/test_issue2051_duplicate_assistant_answer.py pins why the helper exists, which answers a review note: the block is parsed into a template and the nodes are MOVED, so a repeated call adds nothing, and insertAdjacentHTML survives only as the fallback. All three insertions go through it.

tests/test_anchor_fallback_ownership.py, tests/test_compaction_current_summary_fallback.py, tests/test_sprint49.py, tests/test_status_command_card.py: 34 passed. The three #2051 files: 12 passed.
2026-09-21 19:28:49 +01:00

333 lines
11 KiB
Python

"""Regression tests for issue #463: WebUI /status info card.
/status should be a client-handled slash command that renders a safe,
ephemeral assistant-style card from already-loaded session/profile/model data.
It must not round-trip through the agent or a status endpoint just to draw the
card.
"""
import json
import pathlib
import shutil
import subprocess
import tempfile
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent
COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
if depth == 0:
return src[start:idx + 1]
raise AssertionError(f"Could not extract {name}()")
def _run_node(source: str) -> str:
if NODE is None:
pytest.skip("node not on PATH")
with tempfile.NamedTemporaryFile(
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
) as script:
script.write(source)
script_path = pathlib.Path(script.name)
try:
result = subprocess.run(
[NODE, str(script_path)],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
finally:
script_path.unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout.strip()
def _status_render_probe() -> dict:
render_body = _function_body(UI_JS, "renderMessages")
return json.loads(
_run_node(
f"""
const renderBody = {render_body!r};
function extractBlock(startMarker, endMarker) {{
const start = renderBody.indexOf(startMarker);
if (start === -1) throw new Error(`missing start marker: ${{startMarker}}`);
const end = renderBody.indexOf(endMarker, start);
if (end === -1) throw new Error(`missing end marker: ${{endMarker}}`);
return renderBody.slice(start, end);
}}
function makeSeg() {{
return {{
html: '',
classList: {{
added: [],
add(value) {{ this.added.push(value); }},
}},
insertAdjacentHTML(_where, html) {{ this.html += String(html || ''); }},
}};
}}
// #2051: renderMessages() inserts a message block through this helper so a wrapped
// DOM API cannot render it twice. Outside a browser the helper's own fallback is
// insertAdjacentHTML, which is exactly what it does here.
function _insertSegmentBlock(seg, html) {{
seg.insertAdjacentHTML('beforeend', html);
}}
const ordinaryBlock = extractBlock(
"const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);",
"_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);"
);
const orderedBlock = extractBlock(
"if(isLastTextPart&&statusHtml){{",
"blocks.appendChild(orderedSeg);"
);
function runBaseOrdinary(opts) {{
const seg = makeSeg();
const content = opts.content || '';
const filesHtml = opts.filesHtml || '';
const statusHtml = opts.statusHtml || '';
const recoveryHtml = opts.recoveryHtml || '';
const bodyHtml = opts.bodyHtml || '';
const footHtml = opts.footHtml || '';
const thinkingText = opts.thinkingText || '';
const window = {{ _showThinking: opts.showThinking !== false }};
function isSimplifiedToolCalling() {{ return !!opts.simplified; }}
const hasVisibleBody = !!(String(content || '').trim() || filesHtml || statusHtml || recoveryHtml);
if (statusHtml) {{
seg.insertAdjacentHTML('beforeend', statusHtml);
}} else if (hasVisibleBody) {{
seg.insertAdjacentHTML('beforeend', `${{filesHtml}}<div class="msg-body">${{bodyHtml}}</div>${{footHtml}}`);
}} else if (!(thinkingText && window._showThinking !== false && !isSimplifiedToolCalling())) {{
seg.classList.add('assistant-segment-anchor');
}}
return seg;
}}
function runHeadOrdinary(opts) {{
const seg = makeSeg();
const content = opts.content || '';
const filesHtml = opts.filesHtml || '';
const statusHtml = opts.statusHtml || '';
const recoveryHtml = opts.recoveryHtml || '';
const bodyHtml = opts.bodyHtml || '';
const footHtml = opts.footHtml || '';
const thinkingText = opts.thinkingText || '';
const window = {{ _showThinking: opts.showThinking !== false }};
function isSimplifiedToolCalling() {{ return !!opts.simplified; }}
eval(ordinaryBlock);
return seg;
}}
function runOrderedSpecial(opts) {{
const orderedSeg = makeSeg();
const isLastTextPart = opts.isLastTextPart ?? true;
const statusHtml = opts.statusHtml || '';
const filesHtml = opts.filesHtml || '';
const footHtml = opts.footHtml || '';
const partBodyHtml = opts.partBodyHtml || '';
eval(orderedBlock);
return orderedSeg;
}}
const ordinaryInput = {{
content: 'Final report',
bodyHtml: '<p>Final report</p>',
statusHtml: '<status-card>limit</status-card>',
filesHtml: '',
footHtml: '<footer>meta</footer>',
recoveryHtml: '',
}};
const ordinaryBase = runBaseOrdinary(ordinaryInput);
const ordinaryHead = runHeadOrdinary(ordinaryInput);
const statusOnly = runHeadOrdinary({{
statusHtml: '<status-card>limit</status-card>',
content: '',
bodyHtml: '',
filesHtml: '',
footHtml: '<footer>meta</footer>',
recoveryHtml: '',
}});
const ordinaryNoStatus = runHeadOrdinary({{
statusHtml: '',
content: 'Final report',
bodyHtml: '<p>Final report</p>',
filesHtml: '',
footHtml: '<footer>meta</footer>',
recoveryHtml: '',
}});
const filesOnlyWithStatus = runHeadOrdinary({{
statusHtml: '<status-card>limit</status-card>',
content: '',
bodyHtml: '',
filesHtml: '<div class="msg-files">artifact.txt</div>',
footHtml: '',
recoveryHtml: '',
}});
const orderedSpecial = runOrderedSpecial({{
statusHtml: '<status-card>limit</status-card>',
filesHtml: '',
footHtml: '<footer>meta</footer>',
partBodyHtml: '<p>Final report</p>',
}});
console.log(JSON.stringify({{
ordinary: {{
baseHtml: ordinaryBase.html,
headHtml: ordinaryHead.html,
headHasBody: ordinaryHead.html.includes('<div class="msg-body"><p>Final report</p></div>'),
headStatusBeforeBody:
ordinaryHead.html.indexOf('<status-card>limit</status-card>') <
ordinaryHead.html.indexOf('<div class="msg-body"><p>Final report</p></div>'),
baseDropsBody: !ordinaryBase.html.includes('<div class="msg-body"><p>Final report</p></div>'),
}},
statusOnly: {{
html: statusOnly.html,
isCardOnly: statusOnly.html === '<status-card>limit</status-card>',
}},
ordinaryNoStatus: {{
html: ordinaryNoStatus.html,
isBodyOnly:
ordinaryNoStatus.html === '<div class="msg-body"><p>Final report</p></div><footer>meta</footer>',
}},
filesOnlyWithStatus: {{
html: filesOnlyWithStatus.html,
hasFiles: filesOnlyWithStatus.html.includes('<div class="msg-files">artifact.txt</div>'),
statusBeforeFiles:
filesOnlyWithStatus.html.indexOf('<status-card>limit</status-card>') <
filesOnlyWithStatus.html.indexOf('<div class="msg-files">artifact.txt</div>'),
}},
orderedSpecial: {{
html: orderedSpecial.html,
statusCount: (orderedSpecial.html.match(/<status-card>/g) || []).length,
bodyCount: (orderedSpecial.html.match(/<div class="msg-body">/g) || []).length,
}},
}}));
"""
)
)
def test_status_command_is_registered_with_help_text():
assert "{name:'status'" in COMMANDS_JS
assert "desc:t('cmd_status')" in COMMANDS_JS
assert "fn:cmdStatus" in COMMANDS_JS
assert "cmd_status:'Show session info'" in I18N_JS
def test_status_command_uses_client_state_not_status_endpoint():
body = _function_body(COMMANDS_JS, "cmdStatus")
assert "/api/session/status" not in body
assert "api(" not in body
assert "S.session" in body
assert "S.activeProfile" in COMMANDS_JS
assert "model_provider" in COMMANDS_JS
assert "last_usage" in COMMANDS_JS
def test_status_command_pushes_ephemeral_status_card_message():
body = _function_body(COMMANDS_JS, "cmdStatus")
assert "_statusCard" in body
assert "_ephemeral:true" in body
assert "renderMessages()" in body
assert "_statusCardFromSession(S.session)" in body
helper = _function_body(COMMANDS_JS, "_statusCardFromSession")
assert "session_id" in helper
assert "updated_at" in helper
assert "message_count" in helper
assert "active_stream_id" in helper
def test_status_card_renderer_escapes_all_dynamic_values_and_is_copyable():
body = _function_body(UI_JS, "_statusCardHtml")
assert "data-status-card" in body
assert "data-copy-status-session" in body
assert "onclick=\"copyStatusSessionId(this);event.stopPropagation()\"" in body
assert "esc(card.title" in body
assert "esc(card.subtitle" in body
assert "esc(row.label" in body
assert "esc(row.value" in body
assert "esc(card.sessionId" in body
assert "renderMd(" not in body, "Status card data should not be interpreted as markdown"
def test_render_messages_treats_status_card_as_visible_assistant_content():
render_body = _function_body(UI_JS, "renderMessages")
assert "m._statusCard" in render_body
assert "_statusCardHtml(m._statusCard)" in render_body
assert "statusHtml" in render_body
def test_render_messages_keeps_final_report_when_status_card_present():
probe = _status_render_probe()
ordinary = probe["ordinary"]
assert ordinary["baseDropsBody"] is True
assert ordinary["headHasBody"] is True
assert ordinary["headStatusBeforeBody"] is True
def test_render_messages_keeps_status_only_card_boundary():
probe = _status_render_probe()
status_only = probe["statusOnly"]
assert status_only["isCardOnly"] is True
assert status_only["html"] == "<status-card>limit</status-card>"
def test_render_messages_keeps_body_only_boundary_without_status_card():
probe = _status_render_probe()
ordinary_no_status = probe["ordinaryNoStatus"]
assert ordinary_no_status["isBodyOnly"] is True
assert ordinary_no_status["html"] == "<div class=\"msg-body\"><p>Final report</p></div><footer>meta</footer>"
def test_render_messages_keeps_files_when_status_card_present():
probe = _status_render_probe()
files_only = probe["filesOnlyWithStatus"]
assert files_only["hasFiles"] is True
assert files_only["statusBeforeFiles"] is True
def test_ordered_transparent_branch_keeps_single_status_card_boundary():
probe = _status_render_probe()
ordered = probe["orderedSpecial"]
assert ordered["statusCount"] == 1
assert ordered["bodyCount"] == 1
assert ordered["html"] == "<status-card>limit</status-card><div class=\"msg-body\"><p>Final report</p></div><footer>meta</footer>"
def test_status_card_styles_exist():
assert ".status-card" in STYLE_CSS
assert ".status-card-grid" in STYLE_CSS
assert ".status-card-session-copy" in STYLE_CSS
def test_status_command_never_reaches_agent_send_path():
send_body = _function_body(MESSAGES_JS, "send")
branch_start = send_body.index("if(text.startsWith('/')")
branch_end = send_body.index("if(_parsedCmd&&!_cmd)", branch_start)
cmd_branch = send_body[branch_start:branch_end]
assert "COMMANDS.find" in cmd_branch
assert "return;" in cmd_branch
assert "api('/api/chat/start'" not in cmd_branch