Files
hermes-webui/tests/test_issue6008_sidebar_stop_failure_ui.py
T

239 lines
9.2 KiB
Python

"""Sidebar stop must not settle the UI when /api/chat/cancel fails."""
from __future__ import annotations
import json
import pathlib
import re
import subprocess
REPO = pathlib.Path(__file__).parent.parent
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
def _extract_function(src: str, name: str) -> str:
m = re.search(rf"(?:async )?function {name}\s*\(", src)
assert m, f"{name} not found in static/boot.js"
brace_pos = src.index("{", m.end())
depth = 1
pos = brace_pos + 1
while pos < len(src) and depth > 0:
ch = src[pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
pos += 1
return src[m.start():pos]
CANCEL_SESSION_STREAM_SRC = _extract_function(BOOT_JS, "cancelSessionStream")
COMPOSER_PRIMARY_ACTION_SRC = _extract_function(UI_JS, "handleComposerPrimaryAction")
_TOAST_DEFAULT_NAME = "TOAST_" + "DEFAULT_MS"
_TOAST_ERROR_DEFAULT_NAME = "TOAST_ERROR_" + "DEFAULT_MS"
SHOW_TOAST_SRC = _extract_function(UI_JS, "show" + "Toast").replace(
_TOAST_DEFAULT_NAME, "toast_default_ms"
).replace(_TOAST_ERROR_DEFAULT_NAME, "toast_error_default_ms")
def test_source_gates_sidebar_settle_on_http_success():
assert "return false" in CANCEL_SESSION_STREAM_SRC
assert "return true" in CANCEL_SESSION_STREAM_SRC
assert "r.ok" in CANCEL_SESSION_STREAM_SRC, (
"cancelSessionStream() must check the /api/chat/cancel HTTP status before "
"closing local UI state"
)
assert "if(!respOk)returnfalse;" in "".join(CANCEL_SESSION_STREAM_SRC.split()), (
"cancelSessionStream() must bail out on failed stop responses"
)
def test_stop_callers_gate_success_toasts_on_cancel_result():
compact_commands = "".join(COMMANDS_JS.split())
compact_messages = "".join(MESSAGES_JS.split())
compact_sessions = "".join(SESSIONS_JS.split())
assert (
"if(awaitcancelStream('slash-stop'))showToast(t('stream_stopped'));"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_commands
assert (
"if(awaitcancelStream('slash-interrupt'))showToast(t('cmd_interrupt_confirm'),2000);"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_commands
assert (
"if(awaitcancelStream('busy-interrupt'))showToast(t('busy_interrupt_confirm'),2000);"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_messages
assert (
"if(awaitcancelSessionStream(session))showToast(t('stream_stopped'));"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_sessions
assert (
"if(typeofcancelStream==='function'&&!awaitcancelStream('composer-stop'))"
"showToast(t('cancel_failed'),null,'error');"
) in "".join(UI_JS.split())
_NODE_SCRIPT = r'''
const M = {
closeCalls: [],
busyCalls: [],
composerCalls: [],
statusCalls: [],
renderCalls: 0,
clearCalls: [],
approvalStops: 0,
approvalHides: 0,
clarifyStops: 0,
clarifyHides: 0,
fetchCalls: [],
};
globalThis.INFLIGHT = { 'sid-1': { streamId: 'stream-1' } };
globalThis.S = { activeStreamId: 'stream-1', session: { session_id: 'sid-1', active_stream_id: 'stream-1' } };
globalThis.closeLiveStream = (...a) => M.closeCalls.push(a);
globalThis.clearInflightState = (sid) => M.clearCalls.push(['clearInflightState', sid]);
globalThis.clearInflight = () => M.clearCalls.push(['clearInflight']);
globalThis.setBusy = (v) => M.busyCalls.push(v);
globalThis.setComposerStatus = (v) => M.composerCalls.push(v);
globalThis.setStatus = (v) => M.statusCalls.push(v);
globalThis.stopApprovalPolling = () => M.approvalStops += 1;
globalThis.hideApprovalCard = () => M.approvalHides += 1;
globalThis.stopClarifyPolling = () => M.clarifyStops += 1;
globalThis.hideClarifyCard = () => M.clarifyHides += 1;
globalThis.renderSessionList = () => M.renderCalls += 1;
globalThis._approvalSessionId = 'sid-1';
globalThis._clarifySessionId = 'sid-1';
globalThis.document = { baseURI: 'http://localhost:8787/' };
globalThis.location = { href: 'http://localhost:8787/' };
globalThis.fetch = (url, opts) => {
M.fetchCalls.push({ url: String(url), opts });
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ ok: false, cancelled: false, stream_id: 'stream-1' }),
});
};
__CANCEL_SESSION_STREAM_SRC__
const session = { session_id: 'sid-1', active_stream_id: 'stream-1' };
await cancelSessionStream(session);
console.log(JSON.stringify({
sessionActiveStreamId: session.active_stream_id,
activeStreamId: globalThis.S.activeStreamId,
closeCalls: M.closeCalls,
busyCalls: M.busyCalls,
composerCalls: M.composerCalls,
renderCalls: M.renderCalls,
clearCalls: M.clearCalls,
approvalStops: M.approvalStops,
approvalHides: M.approvalHides,
clarifyStops: M.clarifyStops,
clarifyHides: M.clarifyHides,
fetchCalls: M.fetchCalls.length,
}));
'''
def test_failed_sidebar_stop_keeps_local_state():
script = _NODE_SCRIPT.replace("__CANCEL_SESSION_STREAM_SRC__", CANCEL_SESSION_STREAM_SRC)
completed = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=str(REPO),
capture_output=True,
encoding="utf-8",
timeout=30,
)
assert completed.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
)
result = json.loads(completed.stdout.splitlines()[-1])
assert result["fetchCalls"] == 1
assert result["sessionActiveStreamId"] == "stream-1"
assert result["activeStreamId"] == "stream-1"
assert result["closeCalls"] == []
assert result["busyCalls"] == []
assert result["composerCalls"] == []
assert result["renderCalls"] == 0
assert result["clearCalls"] == []
assert result["approvalStops"] == 0
assert result["approvalHides"] == 0
assert result["clarifyStops"] == 0
assert result["clarifyHides"] == 0
def test_primary_composer_stop_renders_localized_error_and_preserves_success():
cancel_key = "cancel_" + "failed"
english = re.search(rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS)
japanese = re.search(
rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS[I18N_JS.index("ja:"):]
)
assert english and japanese
english_message = english.group(1).replace("\\'", "'")
japanese_message = japanese.group(1).replace("\\'", "'")
script = (r'''
const M = { renders: [], sends: 0, results: [] };
const toast_default_ms = 2800;
const toast_error_default_ms = 20000;
const toast = {
className: '', dataset: {}, _innerHTML: '', _textContent: '',
classList: { remove() {} },
};
Object.defineProperty(toast, 'innerHTML', {
get() { return this._innerHTML; },
set(value) { this._innerHTML = String(value); this._textContent = ''; },
});
Object.defineProperty(toast, 'textContent', {
get() { return this._textContent; },
set(value) { this._textContent = String(value); this._innerHTML = ''; },
});
globalThis.$ = (id) => id === 'toast' ? toast : null;
globalThis.esc = (value) => String(value);
globalThis.clearToastDismissTimer = () => {};
globalThis.setToastDismissTimer = (el, duration) => {
M.renders.push({ message: el.dataset.toastMessage, className: el.className,
duration, copy: el.innerHTML.includes('data-toast-' + 'copy="1"') });
};
globalThis.setTimeout = () => 0;
globalThis.window = {};
globalThis.S = {};
globalThis.getComposerPrimaryAction = () => 'stop';
__SHOW_TOAST_SRC__
globalThis.send = () => { M.sends += 1; };
globalThis.cancelStream = async () => M.cancelResult;
__COMPOSER_PRIMARY_ACTION_SRC__
for (const [message, result] of [[__ENGLISH__, false], [__JAPANESE__, false], ['unused', true]]) {
M.cancelResult = result;
globalThis.t = () => message;
const before = M.renders.length;
await handleComposerPrimaryAction();
M.results.push({ result, rendered: M.renders.length - before, sends: M.sends });
}
console.log(JSON.stringify(M));
'''.replace("__SHOW_TOAST_SRC__", SHOW_TOAST_SRC)
.replace("__COMPOSER_PRIMARY_ACTION_SRC__", COMPOSER_PRIMARY_ACTION_SRC)
.replace("__ENGLISH__", json.dumps(english_message))
.replace("__JAPANESE__", json.dumps(japanese_message)))
completed = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=str(REPO), capture_output=True, encoding="utf-8", timeout=30,
)
assert completed.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
)
result = json.loads(completed.stdout.splitlines()[-1])
assert result["renders"] == [
{"message": english_message, "className": "toast show error", "duration": 20000, "copy": True},
{"message": japanese_message, "className": "toast show error", "duration": 20000, "copy": True},
]
assert result["results"] == [
{"result": False, "rendered": 1, "sends": 0},
{"result": False, "rendered": 1, "sends": 0},
{"result": True, "rendered": 0, "sends": 0},
]