mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
238 lines
9.0 KiB
Python
238 lines
9.0 KiB
Python
"""Frontend regression coverage for Update Now apply failures (#1321)."""
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UI_JS = ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _ui_js() -> str:
|
|
return UI_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _run_apply_updates_harness(update_data, responses):
|
|
if NODE is None:
|
|
pytest.skip("node not available for applyUpdates harness")
|
|
js = r"""
|
|
const fs = require('fs');
|
|
const updateData = JSON.parse(process.argv[1]);
|
|
const responses = JSON.parse(process.argv[2]);
|
|
const uiPath = process.argv[3];
|
|
const src = fs.readFileSync(uiPath, 'utf8');
|
|
const start = src.indexOf('async function applyUpdates()');
|
|
const end = src.indexOf('function _showUpdateError', start);
|
|
const snippet = src.slice(start, end);
|
|
|
|
const button = { disabled: false, textContent: 'Update Now' };
|
|
const errorEl = { style: { display: 'none' }, textContent: '' };
|
|
const forceBtn = { style: { display: 'block' }, dataset: { target: 'stale-target' } };
|
|
const dom = {
|
|
btnApplyUpdate: button,
|
|
updateError: errorEl,
|
|
btnForceUpdate: forceBtn,
|
|
};
|
|
const sessionStorage = {
|
|
removed: [],
|
|
removeItem(key) { this.removed.push(key); },
|
|
};
|
|
const showToasts = [];
|
|
const apiCalls = [];
|
|
const waitCalls = [];
|
|
const errors = [];
|
|
let readHealthCalls = 0;
|
|
|
|
function $(id) { return dom[id] || null; }
|
|
async function api(url, opts) {
|
|
const payload = JSON.parse(opts.body);
|
|
apiCalls.push(payload.target);
|
|
const res = responses[apiCalls.length - 1];
|
|
if (res.throwMessage) throw new Error(res.throwMessage);
|
|
return res;
|
|
}
|
|
async function _readHealthServerIdentity() {
|
|
readHealthCalls += 1;
|
|
return 'baseline-id';
|
|
}
|
|
function _waitForServerThenReload(opts) {
|
|
waitCalls.push({
|
|
baselineServerIdentity: opts.baselineServerIdentity,
|
|
apiCallsSnapshot: apiCalls.slice(),
|
|
});
|
|
}
|
|
function _showUpdateError(target, res) { errors.push({ target, message: res.message || '' }); }
|
|
function _formatUpdateApplyExceptionMessage(e) { return 'Update failed: ' + e.message; }
|
|
function showToast(message, duration, kind) {
|
|
showToasts.push({ message, duration, kind });
|
|
}
|
|
function setTimeout(cb, ms) { cb(); return 1; }
|
|
function clearTimeout() {}
|
|
|
|
global.window = { _updateApplyInFlight: false, _updateData: updateData };
|
|
global.sessionStorage = sessionStorage;
|
|
global.$ = $;
|
|
global.api = api;
|
|
global._readHealthServerIdentity = _readHealthServerIdentity;
|
|
global._waitForServerThenReload = _waitForServerThenReload;
|
|
global._showUpdateError = _showUpdateError;
|
|
global._formatUpdateApplyExceptionMessage = _formatUpdateApplyExceptionMessage;
|
|
global.showToast = showToast;
|
|
global.setTimeout = setTimeout;
|
|
global.clearTimeout = clearTimeout;
|
|
|
|
eval(snippet);
|
|
|
|
(async () => {
|
|
await applyUpdates();
|
|
console.log(JSON.stringify({
|
|
apiCalls,
|
|
waitCalls,
|
|
errors,
|
|
showToasts,
|
|
errorDisplay: errorEl.style.display,
|
|
errorText: errorEl.textContent,
|
|
removedKeys: sessionStorage.removed,
|
|
inFlight: window._updateApplyInFlight,
|
|
buttonDisabled: button.disabled,
|
|
buttonText: button.textContent,
|
|
forceHidden: forceBtn.style.display,
|
|
forceTarget: forceBtn.dataset.target,
|
|
readHealthCalls,
|
|
}));
|
|
})().catch((error) => {
|
|
console.error(error.stack || String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
result = subprocess.run(
|
|
[NODE, "-e", js, json.dumps(update_data), json.dumps(responses), str(UI_JS)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"node harness failed: {result.stderr or result.stdout}")
|
|
return json.loads(result.stdout.strip().splitlines()[-1])
|
|
|
|
|
|
def test_update_apply_network_error_has_recovery_message_not_raw_failed_to_fetch():
|
|
"""Network/interrupted update apply failures should not surface raw fetch text alone."""
|
|
src = _ui_js()
|
|
assert "function _formatUpdateApplyExceptionMessage" in src
|
|
assert "could not reach the WebUI server" in src
|
|
assert "restarted or the connection was interrupted" in src
|
|
assert "wait a few seconds, reload the page, then check the server" in src
|
|
assert "Update failed: '+e.message" not in src
|
|
assert 'Update failed: "+e.message' not in src
|
|
|
|
|
|
def test_update_apply_structured_server_errors_still_use_json_message_path():
|
|
"""Server-reachable JSON errors must keep the existing targeted message path."""
|
|
src = _ui_js()
|
|
apply_start = src.index("async function applyUpdates()")
|
|
show_error_call = src.index("_showUpdateError(target,res);", apply_start)
|
|
reset_button = src.index("resetApplyButton(0);", show_error_call)
|
|
assert show_error_call < reset_button
|
|
assert "const msg='Update failed ('+target+'): '+(res.message||'unknown error');" in src
|
|
|
|
|
|
def test_update_apply_successful_stash_conflict_displays_recovery_message():
|
|
"""ok=True stash conflicts must show the server recovery message before restarting."""
|
|
src = _ui_js()
|
|
apply_start = src.index("async function applyUpdates()")
|
|
next_fn = src.index("function _showUpdateError", apply_start)
|
|
body = src[apply_start:next_fn]
|
|
|
|
messages_decl = body.index("const stashConflictMessages=[];")
|
|
stash_branch = body.index("if(res.stash_conflict)")
|
|
message_push = body.index("stashConflictMessages.push('Update applied ('+target+'):", stash_branch)
|
|
persistent_display = body.index("errEl.textContent=stashConflictMessages.join('\\n\\n')", message_push)
|
|
message_join = body.index("const stashConflictMessage=stashConflictMessages.join('\\n\\n');", persistent_display)
|
|
restart_wait = body.index("_waitForServerThenReload", message_join)
|
|
|
|
assert messages_decl < stash_branch < message_push < persistent_display < message_join < restart_wait
|
|
assert "showToast(stashConflictMessage||'Update applied" in body
|
|
assert "stashConflictMessages.length?10000" in body
|
|
|
|
|
|
def test_update_apply_multiple_stash_conflicts_are_aggregated_not_overwritten():
|
|
"""Multiple ok=True stash conflicts must preserve every target recovery message."""
|
|
src = _ui_js()
|
|
apply_start = src.index("async function applyUpdates()")
|
|
next_fn = src.index("function _showUpdateError", apply_start)
|
|
body = src[apply_start:next_fn]
|
|
|
|
assert "let stashConflictMessage='';" not in body
|
|
assert "stashConflictMessage='Update applied ('+target+'):" not in body
|
|
assert "const stashConflictMessages=[];" in body
|
|
assert "stashConflictMessages.push('Update applied ('+target+'): " in body
|
|
assert "errEl.textContent=stashConflictMessages.join('\\n\\n')" in body
|
|
assert "const stashConflictMessage=stashConflictMessages.join('\\n\\n');" in body
|
|
assert "showToast(stashConflictMessage||'Update applied" in body
|
|
|
|
|
|
def test_update_apply_network_error_classifier_ignores_http_status_errors():
|
|
"""HTTP response errors should not be classified as interrupted transport failures."""
|
|
src = _ui_js()
|
|
fn_start = src.index("function _isUpdateApplyNetworkError(error)")
|
|
fn_end = src.index("function _formatUpdateApplyExceptionMessage", fn_start)
|
|
body = src[fn_start:fn_end]
|
|
compact = re.sub(r"\s+", "", body)
|
|
assert "if(error&&error.status)returnfalse;" in compact
|
|
assert body.index("error.status") < body.index("/Failed to fetch|NetworkError|Load failed/i")
|
|
assert "Failed to fetch|NetworkError|Load failed" in body
|
|
|
|
|
|
def test_update_apply_prevents_duplicate_apply_requests_while_in_flight():
|
|
"""Double-clicks should not send a second update apply request during restart race windows."""
|
|
src = _ui_js()
|
|
apply_start = src.index("async function applyUpdates()")
|
|
next_fn = src.index("function _showUpdateError", apply_start)
|
|
body = src[apply_start:next_fn]
|
|
assert "window._updateApplyInFlight" in body
|
|
assert "if(window._updateApplyInFlight) return;" in body
|
|
assert "window._updateApplyInFlight=true;" in body
|
|
assert "window._updateApplyInFlight=false;" in body
|
|
|
|
|
|
def test_update_apply_rejects_zero_target_success_path():
|
|
"""Update Now must return before the toast and reload flow when nothing is behind."""
|
|
result = _run_apply_updates_harness(
|
|
{"agent": {"behind": 0}, "webui": {"behind": 0}},
|
|
[],
|
|
)
|
|
assert result["apiCalls"] == []
|
|
assert result["waitCalls"] == []
|
|
assert result["showToasts"] == []
|
|
assert result["errorDisplay"] == "block"
|
|
assert "No update target selected" in result["errorText"]
|
|
|
|
|
|
def test_apply_updates_queues_agent_before_webui():
|
|
result = _run_apply_updates_harness(
|
|
{"agent": {"behind": 1}, "webui": {"behind": 1}},
|
|
[{"ok": True}, {"ok": True}],
|
|
)
|
|
assert result["apiCalls"] == ["agent", "webui"]
|
|
assert result["waitCalls"] == [
|
|
{
|
|
"baselineServerIdentity": "baseline-id",
|
|
"apiCallsSnapshot": ["agent", "webui"],
|
|
}
|
|
]
|
|
|
|
|
|
def test_apply_updates_wait_for_all_targets_before_reload():
|
|
result = _run_apply_updates_harness(
|
|
{"agent": {"behind": 1}, "webui": {"behind": 1}},
|
|
[{"ok": True}, {"ok": False, "message": "webui failed"}],
|
|
)
|
|
assert result["apiCalls"] == ["agent", "webui"]
|
|
assert result["waitCalls"] == []
|
|
assert result["errors"] == [{"target": "webui", "message": "webui failed"}]
|