Files
hermes-webui/tests/test_issue4771_gateway_approval_failure_ui.py

413 lines
16 KiB
Python

"""Tests for #4771: failed gateway approval clicks must keep the card actionable."""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _extract_fn(src: str, name: str, prefix: str = "function ") -> str:
start = src.index(f"{prefix}{name}(")
paren = src.index("(", start)
paren_depth = 0
for i in range(paren, len(src)):
if src[i] == "(":
paren_depth += 1
elif src[i] == ")":
paren_depth -= 1
if paren_depth == 0:
brace = src.index("{", i + 1)
break
else:
raise AssertionError(f"{name} parameter list not closed")
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start:i + 1]
raise AssertionError(f"{name} body not closed")
def _run_node(script: str) -> dict:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as tf:
tf.write(script)
script_path = tf.name
try:
result = subprocess.run(
[NODE, script_path],
check=False,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
finally:
Path(script_path).unlink(missing_ok=True)
def _run_failure_case(api_js: str) -> dict:
helpers = "\n".join(
[
_extract_fn(MESSAGES_JS, "_approvalDismissKey"),
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
_extract_fn(MESSAGES_JS, "showApprovalCard"),
_extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
_extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
]
)
script = f"""
const output = {{}};
const _store = {{}};
const localStorage = {{
getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
setItem: (key, value) => {{ _store[key] = String(value); }},
}};
const S = {{ session: {{ session_id: 'sess-1' }} }};
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let syncTopbarCalls = 0;
let showToastCalls = [];
let statusCalls = [];
let hideCalls = 0;
let renderCalls = 0;
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
const buttons = new Map();
function makeButton(id) {{
return {{
id,
disabled: false,
classList: {{
values: new Set(),
add(value) {{ this.values.add(value); }},
remove(value) {{ this.values.delete(value); }},
contains(value) {{ return this.values.has(value); }},
}},
}};
}}
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
const card = {{
classList: {{
values: new Set(['visible']),
add(value) {{ this.values.add(value); }},
remove(value) {{ this.values.delete(value); }},
contains(value) {{ return this.values.has(value); }},
toggle(value, force) {{
if (force === undefined) {{
if (this.values.has(value)) this.values.delete(value);
else this.values.add(value);
}} else if (force) this.values.add(value);
else this.values.delete(value);
}},
}},
hidden: false,
attributes: {{}},
setAttribute(name, value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
querySelector() {{ return null; }},
}};
const approvalDesc = {{ textContent: '' }};
const approvalCmd = {{ textContent: '' }};
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
const msg = {{}};
const document = {{ activeElement: null }};
const elements = {{
approvalCard: card,
approvalDesc,
approvalCmd,
approvalCounter,
approvalBtnOnce: buttons.get('approvalBtnOnce'),
approvalBtnSession: buttons.get('approvalBtnSession'),
approvalBtnAlways: buttons.get('approvalBtnAlways'),
approvalBtnDeny: buttons.get('approvalBtnDeny'),
msg,
}};
function $(id) {{ return elements[id] || null; }}
function syncTopbar() {{ syncTopbarCalls += 1; }}
function hideApprovalCard() {{ hideCalls += 1; card.classList.remove('visible'); }}
function _clearApprovalHideTimer() {{}}
function _syncApprovalCollapseButton() {{}}
function _syncApprovalTranscriptSpace() {{}}
function applyLocaleToDOM() {{}}
function setTimeout(fn) {{ return 1; }}
function showToast(msg) {{ showToastCalls.push(msg); }}
function setStatus(msg) {{ statusCalls.push(msg); }}
function t(key) {{ return key; }}
{api_js}
{helpers}
const pending = {{
approval_id: 'appr-1',
_session_id: 'sess-1',
command: 'rm -rf /tmp/test',
description: 'Dangerous command approval',
pattern_key: 'dangerous_command',
pattern_keys: ['dangerous_command'],
}};
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
showApprovalCard(pending, 1);
const realRenderPendingApprovalForActiveSession = _renderPendingApprovalForActiveSession;
renderCalls = 0;
_renderPendingApprovalForActiveSession = function() {{
renderCalls += 1;
return realRenderPendingApprovalForActiveSession();
}};
respondApproval('once').then(() => {{
output.sessionId = _approvalSessionId;
output.approvalId = _approvalCurrentId;
output.pendingApprovalId = _approvalPendingBySession.get('sess-1').pending.approval_id;
output.hideCalls = hideCalls;
output.renderCalls = renderCalls;
output.toast = showToastCalls[0] || null;
output.status = statusCalls[0] || null;
output.onceDisabled = buttons.get('approvalBtnOnce').disabled;
output.onceLoading = buttons.get('approvalBtnOnce').classList.contains('loading');
output.denyDisabled = buttons.get('approvalBtnDeny').disabled;
output.cardVisible = card.classList.contains('visible');
process.stdout.write(JSON.stringify(output));
}});
"""
return _run_node(script)
def test_failed_gateway_approval_keeps_card_and_reenables_buttons():
out = _run_failure_case(
"""function api() {
const err = new Error('Gateway approval could not be relayed because the active run is unavailable. Reopen the session or retry after it reconnects.');
err.status = 409;
return Promise.reject(err);
}"""
)
assert out["sessionId"] == "sess-1"
assert out["approvalId"] == "appr-1"
assert out["pendingApprovalId"] == "appr-1"
assert out["hideCalls"] == 0
assert out["renderCalls"] == 1
assert out["onceDisabled"] is False
assert out["onceLoading"] is False
assert out["denyDisabled"] is False
assert out["cardVisible"] is True
assert "active run is unavailable" in out["toast"]
assert "active run is unavailable" in out["status"]
def test_rejected_ok_false_approval_keeps_card_and_reenables_buttons():
out = _run_failure_case(
"""function api() {
return Promise.resolve({
ok: false,
error: 'Approval response not accepted for this session.',
});
}"""
)
assert out["sessionId"] == "sess-1"
assert out["approvalId"] == "appr-1"
assert out["pendingApprovalId"] == "appr-1"
assert out["hideCalls"] == 0
assert out["renderCalls"] == 1
assert out["onceDisabled"] is False
assert out["onceLoading"] is False
assert out["denyDisabled"] is False
assert out["cardVisible"] is True
assert "Approval response not accepted for this session." == out["toast"]
assert "Approval response not accepted for this session." == out["status"]
def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
helpers = "\n".join(
[
_extract_fn(MESSAGES_JS, "_approvalDismissKey"),
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
_extract_fn(MESSAGES_JS, "_promptNotifyKey"),
_extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
_extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
_extract_fn(MESSAGES_JS, "showApprovalCard"),
_extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
_extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
]
)
script = f"""
const output = {{}};
const _store = {{}};
const localStorage = {{
getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
setItem: (key, value) => {{ _store[key] = String(value); }},
}};
const S = {{ session: {{ session_id: 'sess-1' }} }};
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
let resolveApi;
let apiCalls = 0;
const buttons = new Map();
function makeButton(id) {{
return {{
id,
disabled: false,
classList: {{
values: new Set(),
add(value) {{ this.values.add(value); }},
remove(value) {{ this.values.delete(value); }},
contains(value) {{ return this.values.has(value); }},
}},
}};
}}
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
const card = {{
classList: {{
values: new Set(['visible']),
add(value) {{ this.values.add(value); }},
remove(value) {{ this.values.delete(value); }},
contains(value) {{ return this.values.has(value); }},
toggle(value, force) {{
if (force === undefined) {{
if (this.values.has(value)) this.values.delete(value);
else this.values.add(value);
}} else if (force) this.values.add(value);
else this.values.delete(value);
}},
}},
hidden: false,
attributes: {{}},
setAttribute(name, value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
querySelector() {{ return null; }},
}};
const approvalDesc = {{ textContent: '' }};
const approvalCmd = {{ textContent: '' }};
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
const msg = {{}};
const document = {{ activeElement: null }};
const elements = {{
approvalCard: card,
approvalDesc,
approvalCmd,
approvalCounter,
approvalBtnOnce: buttons.get('approvalBtnOnce'),
approvalBtnSession: buttons.get('approvalBtnSession'),
approvalBtnAlways: buttons.get('approvalBtnAlways'),
approvalBtnDeny: buttons.get('approvalBtnDeny'),
msg,
}};
function $(id) {{ return elements[id] || null; }}
function syncTopbar() {{}}
function hideApprovalCard() {{ card.classList.remove('visible'); }}
function _clearApprovalHideTimer() {{}}
function _syncApprovalCollapseButton() {{}}
function _syncApprovalTranscriptSpace() {{}}
function applyLocaleToDOM() {{}}
function setTimeout(fn) {{ return 1; }}
function showToast() {{}}
function setStatus() {{}}
function t(key) {{ return key; }}
function api() {{
apiCalls += 1;
return new Promise(resolve => {{ resolveApi = resolve; }});
}}
{helpers}
const pending = {{
approval_id: 'appr-1',
_session_id: 'sess-1',
command: 'rm -rf /tmp/test',
description: 'Dangerous command approval',
pattern_key: 'dangerous_command',
pattern_keys: ['dangerous_command'],
}};
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
showApprovalCard(pending, 1);
const first = respondApproval('once');
_renderPendingApprovalForActiveSession();
const second = respondApproval('once');
output.apiCallsDuringFlight = apiCalls;
output.onceDisabledDuringFlight = buttons.get('approvalBtnOnce').disabled;
output.onceLoadingDuringFlight = buttons.get('approvalBtnOnce').classList.contains('loading');
resolveApi({{ ok: false }});
Promise.all([first, second]).then(() => {{
output.apiCallsFinal = apiCalls;
output.onceDisabledAfter = buttons.get('approvalBtnOnce').disabled;
output.onceLoadingAfter = buttons.get('approvalBtnOnce').classList.contains('loading');
output.cardVisibleAfter = card.classList.contains('visible');
process.stdout.write(JSON.stringify(output));
}});
"""
out = _run_node(script)
assert out["apiCallsDuringFlight"] == 1
assert out["onceDisabledDuringFlight"] is True
assert out["onceLoadingDuringFlight"] is True
assert out["apiCallsFinal"] == 1
assert out["onceDisabledAfter"] is False
assert out["onceLoadingAfter"] is False
assert out["cardVisibleAfter"] is True