fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
"""Tests for update banner fixes — #813 (conflict recovery) and #814 (restart after update).
|
|
|
|
|
|
|
|
|
|
Covers:
|
|
|
|
|
- conflict error now includes 'conflict: True' flag and actionable git command (#813)
|
|
|
|
|
- successful update returns 'restart_scheduled: True' (#814)
|
|
|
|
|
- _schedule_restart() spawns a daemon thread, does not block (#814)
|
|
|
|
|
- apply_force_update() returns ok on clean reset path (#813)
|
|
|
|
|
- /api/updates/force route exists in routes.py (#813)
|
|
|
|
|
- UI: _showUpdateError and forceUpdate functions exist in ui.js (#813)
|
|
|
|
|
- UI: updateError element and btnForceUpdate element exist in index.html (#813)
|
|
|
|
|
- UI: success toast says 'Restarting' not 'Reloading' (#814)
|
|
|
|
|
- UI: reload timeout bumped to 2500 ms to allow server restart (#814)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pathlib
|
|
|
|
|
import re
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import sys
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read(rel):
|
|
|
|
|
return (REPO / rel).read_text(encoding='utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── api/updates.py ────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-04 07:29:59 +00:00
|
|
|
class TestUpdateChecker:
|
2026-05-13 05:45:45 +00:00
|
|
|
def test_build_compare_url_requires_all_pieces(self):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
assert upd._build_compare_url(
|
|
|
|
|
'https://github.com/nesquena/hermes-webui', 'abc1234', 'def5678'
|
|
|
|
|
) == 'https://github.com/nesquena/hermes-webui/compare/abc1234...def5678'
|
|
|
|
|
assert upd._build_compare_url(None, 'abc1234', 'def5678') is None
|
|
|
|
|
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', None, 'def5678') is None
|
|
|
|
|
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', 'abc1234', None) is None
|
|
|
|
|
|
|
|
|
|
def test_build_compare_url_rejects_unsafe_remote_urls(self):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
assert upd._build_compare_url('javascript:alert(1)', 'abc1234', 'def5678') is None
|
|
|
|
|
assert upd._build_compare_url('file:///tmp/hermes-webui', 'abc1234', 'def5678') is None
|
|
|
|
|
assert upd._build_compare_url('https:github.com/nesquena/hermes-webui', 'abc1234', 'def5678') is None
|
|
|
|
|
assert upd._build_compare_url('https://github.com/nesquena/hermes-webui', 'abc1234', 'def5678')
|
|
|
|
|
|
|
|
|
|
def test_check_repo_includes_compare_url_from_normalized_remote_and_merge_base(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[:2] == ['rev-list', '--count']:
|
|
|
|
|
return '2', True
|
|
|
|
|
if args[0] == 'merge-base':
|
|
|
|
|
return 'abcdef1234567890', True
|
|
|
|
|
if args[:3] == ['rev-parse', '--short', 'abcdef1234567890']:
|
|
|
|
|
return 'abcdef1', True
|
|
|
|
|
if args[:3] == ['rev-parse', '--short', 'origin/master']:
|
|
|
|
|
return 'def5678', True
|
|
|
|
|
if args[:2] == ['remote', 'get-url']:
|
|
|
|
|
return 'git@github.com:NousResearch/hermes-agent.git', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
result = upd._check_repo(tmp_path, 'agent')
|
|
|
|
|
|
|
|
|
|
assert result['repo_url'] == 'https://github.com/NousResearch/hermes-agent'
|
|
|
|
|
assert result['current_sha'] == 'abcdef1'
|
|
|
|
|
assert result['latest_sha'] == 'def5678'
|
|
|
|
|
assert result['compare_url'] == 'https://github.com/NousResearch/hermes-agent/compare/abcdef1...def5678'
|
|
|
|
|
|
|
|
|
|
def test_check_repo_omits_compare_url_when_merge_base_missing(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[:2] == ['rev-list', '--count']:
|
|
|
|
|
return '2', True
|
|
|
|
|
if args[0] == 'merge-base':
|
|
|
|
|
return 'fatal: no merge base', False
|
|
|
|
|
if args[:3] == ['rev-parse', '--short', 'origin/master']:
|
|
|
|
|
return 'def5678', True
|
|
|
|
|
if args[:2] == ['remote', 'get-url']:
|
|
|
|
|
return 'https://github.com/nesquena/hermes-webui.git', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
result = upd._check_repo(tmp_path, 'webui')
|
|
|
|
|
|
|
|
|
|
assert result['current_sha'] is None
|
|
|
|
|
assert result['latest_sha'] == 'def5678'
|
|
|
|
|
assert result['compare_url'] is None
|
|
|
|
|
|
2026-05-04 07:29:59 +00:00
|
|
|
def test_repo_url_strips_only_dot_git_suffix(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[:2] == ['rev-list', '--count']:
|
|
|
|
|
return '0', True
|
|
|
|
|
if args[0] == 'merge-base':
|
|
|
|
|
return 'abcdef1234567890', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--short']:
|
|
|
|
|
return 'abcdef1', True
|
|
|
|
|
if args[:2] == ['remote', 'get-url']:
|
|
|
|
|
return 'https://github.com/nesquena/hermes-webui.git', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
result = upd._check_repo(tmp_path, 'webui')
|
|
|
|
|
|
|
|
|
|
assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui'
|
|
|
|
|
|
|
|
|
|
def test_repo_url_converts_ssh_and_strips_only_dot_git_suffix(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/main', True
|
|
|
|
|
if args[:2] == ['rev-list', '--count']:
|
|
|
|
|
return '0', True
|
|
|
|
|
if args[0] == 'merge-base':
|
|
|
|
|
return 'abcdef1234567890', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--short']:
|
|
|
|
|
return 'abcdef1', True
|
|
|
|
|
if args[:2] == ['remote', 'get-url']:
|
|
|
|
|
return 'git@github.com:NousResearch/hermes-agent.git', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
result = upd._check_repo(tmp_path, 'agent')
|
|
|
|
|
|
|
|
|
|
assert result['repo_url'] == 'https://github.com/NousResearch/hermes-agent'
|
|
|
|
|
|
2026-05-05 15:29:00 +00:00
|
|
|
def test_repo_url_strips_dot_git_before_trailing_slashes(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[:2] == ['rev-list', '--count']:
|
|
|
|
|
return '2', True
|
|
|
|
|
if args[0] == 'merge-base':
|
|
|
|
|
return 'abcdef1234567890', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--short']:
|
|
|
|
|
return 'abcdef1', True
|
|
|
|
|
if args[:2] == ['remote', 'get-url']:
|
|
|
|
|
return 'https://github.com/nesquena/hermes-webui.git/', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
result = upd._check_repo(tmp_path, 'webui')
|
|
|
|
|
|
|
|
|
|
assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui'
|
|
|
|
|
|
2026-05-04 07:29:59 +00:00
|
|
|
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
class TestConflictError:
|
|
|
|
|
"""#813 — conflict error must include flag + recovery command."""
|
|
|
|
|
|
|
|
|
|
def test_conflict_returns_conflict_flag(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
# Fake a repo with conflict markers in git status output
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
conflict_status = 'UU some/file.py'
|
|
|
|
|
|
|
|
|
|
calls = []
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
calls.append(args)
|
|
|
|
|
if args[:2] == ['status', '--porcelain']:
|
|
|
|
|
return conflict_status, True
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
|
|
|
|
|
result = upd.apply_update('webui')
|
|
|
|
|
assert result['ok'] is False
|
|
|
|
|
assert result.get('conflict') is True, "conflict flag must be True"
|
|
|
|
|
assert 'checkout' in result['message'] or 'pull' in result['message'], (
|
|
|
|
|
"conflict message must include recovery command"
|
|
|
|
|
)
|
|
|
|
|
assert 'merge conflict' in result['message'].lower()
|
|
|
|
|
|
|
|
|
|
def test_conflict_message_includes_git_command(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[:2] == ['status', '--porcelain']:
|
|
|
|
|
return 'AA conflict.txt', True
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
|
|
|
|
|
result = upd.apply_update('agent')
|
|
|
|
|
# Message must be actionable — should mention git checkout or pull
|
|
|
|
|
msg = result['message']
|
|
|
|
|
assert 'git' in msg.lower(), f"message should mention git: {msg}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestScheduleRestart:
|
|
|
|
|
"""#814 — _schedule_restart must exist and be non-blocking."""
|
|
|
|
|
|
|
|
|
|
def test_schedule_restart_exists(self):
|
|
|
|
|
from api.updates import _schedule_restart
|
|
|
|
|
assert callable(_schedule_restart)
|
|
|
|
|
|
|
|
|
|
def test_schedule_restart_is_nonblocking(self, monkeypatch):
|
|
|
|
|
"""_schedule_restart() must return immediately (spawns daemon thread)."""
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
execv_called = []
|
|
|
|
|
|
|
|
|
|
def fake_execv(exe, args):
|
|
|
|
|
execv_called.append((exe, args))
|
|
|
|
|
|
|
|
|
|
# Monkeypatch os.execv inside the module's thread closure
|
|
|
|
|
import os as _os
|
|
|
|
|
original_execv = _os.execv
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_os, 'execv', fake_execv)
|
|
|
|
|
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
upd._schedule_restart(delay=0.05)
|
|
|
|
|
elapsed = time.monotonic() - start
|
|
|
|
|
|
|
|
|
|
assert elapsed < 0.5, f"_schedule_restart must return immediately, took {elapsed:.2f}s"
|
|
|
|
|
# Give the thread time to call execv
|
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
assert execv_called, "_schedule_restart must eventually call os.execv"
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 21:10:02 +00:00
|
|
|
class TestApplyUpdateRestartSafety:
|
|
|
|
|
"""Self-update must not re-exec while chat streams are active."""
|
|
|
|
|
|
|
|
|
|
def test_apply_update_refuses_when_stream_active(self, tmp_path, monkeypatch):
|
|
|
|
|
import queue
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
from api.config import STREAMS, STREAMS_LOCK
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
called = []
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', lambda *a, **k: (called.append(a) or ('', True)))
|
|
|
|
|
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: (_ for _ in ()).throw(AssertionError('must not restart')))
|
|
|
|
|
|
|
|
|
|
with STREAMS_LOCK:
|
|
|
|
|
old = dict(STREAMS)
|
|
|
|
|
STREAMS.clear()
|
|
|
|
|
STREAMS['stream_active'] = queue.Queue()
|
|
|
|
|
try:
|
|
|
|
|
result = upd.apply_update('webui')
|
|
|
|
|
finally:
|
|
|
|
|
with STREAMS_LOCK:
|
|
|
|
|
STREAMS.clear()
|
|
|
|
|
STREAMS.update(old)
|
|
|
|
|
|
|
|
|
|
assert result['ok'] is False
|
|
|
|
|
assert result.get('active_streams') == 1
|
|
|
|
|
assert result.get('restart_blocked') is True
|
|
|
|
|
assert 'active chat stream' in result['message']
|
|
|
|
|
assert called == []
|
|
|
|
|
|
|
|
|
|
def test_force_update_refuses_when_stream_active(self, tmp_path, monkeypatch):
|
|
|
|
|
import queue
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
from api.config import STREAMS, STREAMS_LOCK
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', lambda *a, **k: (_ for _ in ()).throw(AssertionError('must not run git')))
|
|
|
|
|
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: (_ for _ in ()).throw(AssertionError('must not restart')))
|
|
|
|
|
|
|
|
|
|
with STREAMS_LOCK:
|
|
|
|
|
old = dict(STREAMS)
|
|
|
|
|
STREAMS.clear()
|
|
|
|
|
STREAMS['stream_active'] = queue.Queue()
|
|
|
|
|
try:
|
|
|
|
|
result = upd.apply_force_update('agent')
|
|
|
|
|
finally:
|
|
|
|
|
with STREAMS_LOCK:
|
|
|
|
|
STREAMS.clear()
|
|
|
|
|
STREAMS.update(old)
|
|
|
|
|
|
|
|
|
|
assert result['ok'] is False
|
|
|
|
|
assert result.get('active_streams') == 1
|
|
|
|
|
assert result.get('restart_blocked') is True
|
|
|
|
|
assert 'active chat stream' in result['message']
|
|
|
|
|
|
|
|
|
|
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
class TestSuccessfulUpdateReturnsRestartScheduled:
|
|
|
|
|
"""#814 — successful apply_update must return restart_scheduled: True."""
|
|
|
|
|
|
|
|
|
|
def test_apply_update_returns_restart_scheduled(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['status', '--porcelain']:
|
|
|
|
|
return '', True # clean tree
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[0] == 'pull':
|
|
|
|
|
return 'Already up to date.', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
# Don't actually restart
|
|
|
|
|
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
|
|
|
|
|
|
|
|
|
|
result = upd.apply_update('webui')
|
|
|
|
|
assert result['ok'] is True
|
|
|
|
|
assert result.get('restart_scheduled') is True, (
|
|
|
|
|
"successful update must set restart_scheduled: True"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApplyForceUpdate:
|
|
|
|
|
"""#813 — apply_force_update must reset hard and return ok."""
|
|
|
|
|
|
|
|
|
|
def test_apply_force_update_ok(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
|
|
|
|
|
(tmp_path / '.git').mkdir()
|
|
|
|
|
ran = []
|
|
|
|
|
|
|
|
|
|
def fake_run(args, cwd, timeout=10):
|
|
|
|
|
ran.append(args)
|
|
|
|
|
if args[0] == 'fetch':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
|
|
|
|
return 'origin/master', True
|
|
|
|
|
if args[0] == 'checkout':
|
|
|
|
|
return '', True
|
|
|
|
|
if args[0] == 'reset':
|
|
|
|
|
return '', True
|
|
|
|
|
return '', True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(upd, '_run_git', fake_run)
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
|
|
|
|
|
|
|
|
|
|
result = upd.apply_force_update('webui')
|
|
|
|
|
assert result['ok'] is True
|
|
|
|
|
assert result.get('restart_scheduled') is True
|
|
|
|
|
|
|
|
|
|
git_cmds = [r[0] for r in ran]
|
|
|
|
|
assert 'reset' in git_cmds, "force update must call git reset --hard"
|
|
|
|
|
assert 'checkout' in git_cmds, "force update must call git checkout . to clear conflicts"
|
|
|
|
|
|
|
|
|
|
def test_apply_force_update_rejects_unknown_target(self, tmp_path, monkeypatch):
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
|
|
|
|
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
|
|
|
|
result = upd.apply_force_update('invalid')
|
|
|
|
|
assert result['ok'] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── api/routes.py ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestForceUpdateRoute:
|
|
|
|
|
"""#813 — /api/updates/force route must exist in routes.py."""
|
|
|
|
|
|
|
|
|
|
def test_force_route_exists(self):
|
|
|
|
|
src = read('api/routes.py')
|
|
|
|
|
assert '"/api/updates/force"' in src, (
|
|
|
|
|
"routes.py must handle POST /api/updates/force"
|
|
|
|
|
)
|
|
|
|
|
assert 'apply_force_update' in src, (
|
|
|
|
|
"routes.py must import and call apply_force_update"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── static/ui.js ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestUiJsUpdateBanner:
|
|
|
|
|
"""#813 + #814 — UI must show persistent error, force button, and correct toast."""
|
|
|
|
|
|
|
|
|
|
def test_show_update_error_function_exists(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
assert 'function _showUpdateError' in src, (
|
|
|
|
|
"_showUpdateError() must be defined in ui.js"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_force_update_function_exists(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
assert 'function forceUpdate' in src or 'async function forceUpdate' in src, (
|
|
|
|
|
"forceUpdate() must be defined in ui.js"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_force_update_uses_confirm_dialog_not_native(self):
|
|
|
|
|
"""forceUpdate() must use showConfirmDialog(), not the banned native confirm()."""
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function forceUpdate\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "forceUpdate() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert 'showConfirmDialog' in fn, (
|
|
|
|
|
"forceUpdate() must use showConfirmDialog() not the native confirm() "
|
|
|
|
|
"(native confirm is banned by test_sprint33)"
|
|
|
|
|
)
|
|
|
|
|
assert 'confirm(' not in fn.replace('showConfirmDialog(', ''), (
|
|
|
|
|
"forceUpdate() must not use native confirm()"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_force_update_calls_api_updates_force(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function forceUpdate\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "forceUpdate() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert '/api/updates/force' in fn, (
|
|
|
|
|
"forceUpdate() must POST to /api/updates/force"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_success_toast_says_restarting(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "applyUpdates() not found"
|
|
|
|
|
fn = m.group(0)
|
2026-04-23 00:51:12 +00:00
|
|
|
assert 'restarting' in fn.lower(), (
|
|
|
|
|
"success toast must mention 'restarting' (server self-restarts after update)"
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
)
|
|
|
|
|
assert 'Reloading' not in fn, (
|
|
|
|
|
"success toast must not say 'Reloading' — server restarts, page reloads after"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-23 00:51:12 +00:00
|
|
|
def test_reload_uses_health_poll_not_blind_timeout(self):
|
|
|
|
|
"""applyUpdates must use _waitForServerThenReload() instead of a blind setTimeout.
|
|
|
|
|
|
|
|
|
|
A fixed setTimeout race-loses against slow hardware or reverse proxies
|
|
|
|
|
that return 502 immediately when the upstream socket is down.
|
|
|
|
|
The polling approach retries until /health responds OK.
|
|
|
|
|
"""
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "applyUpdates() not found"
|
|
|
|
|
fn = m.group(0)
|
2026-04-23 00:51:12 +00:00
|
|
|
assert '_waitForServerThenReload' in fn, (
|
|
|
|
|
"applyUpdates() must call _waitForServerThenReload() instead of a blind "
|
|
|
|
|
"setTimeout reload — blind timeouts race-lose against slow restarts and "
|
|
|
|
|
"reverse proxies that 502 immediately on restart."
|
|
|
|
|
)
|
|
|
|
|
assert 'setTimeout(()=>location.reload' not in fn, (
|
|
|
|
|
"applyUpdates() must not use a fixed setTimeout reload — use _waitForServerThenReload()."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_wait_for_server_then_reload_is_defined(self):
|
|
|
|
|
"""_waitForServerThenReload() must actually exist — the original PR
|
|
|
|
|
referenced it from applyUpdates()/forceUpdate() without defining it,
|
|
|
|
|
which would have thrown ReferenceError on 'Update Now'."""
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
assert re.search(r'(async\s+)?function\s+_waitForServerThenReload\b', src), (
|
|
|
|
|
"_waitForServerThenReload() is called but not defined — this breaks "
|
|
|
|
|
"the Update Now flow entirely (ReferenceError at runtime)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_wait_for_server_polls_health(self):
|
2026-05-04 06:57:52 +00:00
|
|
|
"""_waitForServerThenReload() must fetch health to determine readiness."""
|
2026-04-23 00:51:12 +00:00
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function\s+_waitForServerThenReload\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "_waitForServerThenReload() not found"
|
|
|
|
|
fn = m.group(0)
|
2026-05-04 06:57:52 +00:00
|
|
|
assert "new URL('health'" in fn, (
|
|
|
|
|
"_waitForServerThenReload must poll the mount-relative health endpoint "
|
|
|
|
|
"to detect server readiness"
|
2026-04-23 00:51:12 +00:00
|
|
|
)
|
|
|
|
|
assert 'location.reload' in fn, (
|
|
|
|
|
"_waitForServerThenReload must call location.reload() once the server is ready"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_refresh_session_handles_restart_mode(self):
|
|
|
|
|
"""When _restartingForUpdate flag is set, refreshSession() must do a
|
|
|
|
|
full page reload rather than hit /api/session (which will 502 while
|
|
|
|
|
the server is down)."""
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'async function refreshSession\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "refreshSession() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert '_restartingForUpdate' in fn and 'location.reload' in fn, (
|
|
|
|
|
"refreshSession() must check the restart flag and bypass /api/session "
|
|
|
|
|
"when the server is mid-restart."
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_conflict_response_shows_force_button(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function _showUpdateError\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "_showUpdateError() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert 'conflict' in fn or 'diverged' in fn, (
|
|
|
|
|
"_showUpdateError must check res.conflict / res.diverged to show force button"
|
|
|
|
|
)
|
|
|
|
|
assert 'btnForceUpdate' in fn or 'forceBtn' in fn, (
|
|
|
|
|
"_showUpdateError must reference the force update button"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_error_displayed_persistently_not_just_toast(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'function _showUpdateError\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert 'updateError' in fn, (
|
|
|
|
|
"_showUpdateError must write to the #updateError element for persistent display"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 07:46:45 +00:00
|
|
|
class TestUpdateBannerUx:
|
|
|
|
|
def test_update_banner_includes_repo_branch_labels(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
assert 'function _formatUpdateTargetStatus' in src
|
|
|
|
|
assert 'info.branch' in src
|
|
|
|
|
assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src
|
|
|
|
|
assert "_formatUpdateTargetStatus('Agent',data.agent)" in src
|
|
|
|
|
|
|
|
|
|
def test_settings_update_check_uses_same_repo_branch_formatter(self):
|
|
|
|
|
src = read('static/panels.js')
|
|
|
|
|
m = re.search(r'async function checkUpdatesNow\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "checkUpdatesNow() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
assert '_formatUpdateTargetStatus' in fn
|
|
|
|
|
assert "formatUpdatePart('WebUI',data.webui)" in fn
|
|
|
|
|
assert "formatUpdatePart('Agent',data.agent)" in fn
|
|
|
|
|
|
|
|
|
|
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
# ── static/index.html ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestIndexHtmlBanner:
|
|
|
|
|
"""#813 — update banner HTML must include error element and force button."""
|
|
|
|
|
|
|
|
|
|
def test_update_error_element_exists(self):
|
|
|
|
|
src = read('static/index.html')
|
|
|
|
|
assert 'id="updateError"' in src, (
|
|
|
|
|
"index.html must have #updateError element for persistent error display"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_force_update_button_exists(self):
|
|
|
|
|
src = read('static/index.html')
|
|
|
|
|
assert 'id="btnForceUpdate"' in src, (
|
|
|
|
|
"index.html must have #btnForceUpdate button (hidden by default)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_force_update_button_hidden_by_default(self):
|
|
|
|
|
src = read('static/index.html')
|
|
|
|
|
m = re.search(r'id="btnForceUpdate"[^>]*>', src)
|
|
|
|
|
assert m, "#btnForceUpdate not found"
|
|
|
|
|
tag = m.group(0)
|
|
|
|
|
assert 'display:none' in tag, (
|
|
|
|
|
"#btnForceUpdate must be hidden by default (display:none)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Regression: sequential webui+agent update — restart coordination ──────────
|
|
|
|
|
|
|
|
|
|
class TestSequentialUpdateRestartCoordination:
|
|
|
|
|
"""Regression guard for the two-target race: when both webui and agent
|
|
|
|
|
have updates, the client POSTs them sequentially (webui → agent). The
|
|
|
|
|
first update's success schedules a restart timer; without coordination
|
|
|
|
|
that timer fires while the second update's git-pull is still running,
|
|
|
|
|
killing it mid-stream and leaving the second repo partial.
|
|
|
|
|
|
|
|
|
|
Fix: `_schedule_restart` must acquire `_apply_lock` before calling
|
|
|
|
|
`os.execv`, so a pending second update always completes first.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_schedule_restart_waits_for_apply_lock(self, monkeypatch):
|
|
|
|
|
"""The restart thread must wait for any in-flight update before
|
|
|
|
|
calling execv. Exercised by holding _apply_lock from another thread
|
|
|
|
|
and verifying execv is delayed until the lock is released."""
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
import threading as _th
|
|
|
|
|
import time as _t
|
|
|
|
|
|
|
|
|
|
execv_called = _th.Event()
|
|
|
|
|
execv_time = []
|
|
|
|
|
|
|
|
|
|
def fake_execv(exe, args):
|
|
|
|
|
execv_time.append(_t.monotonic())
|
|
|
|
|
execv_called.set()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(os, 'execv', fake_execv)
|
|
|
|
|
|
|
|
|
|
# Hold _apply_lock from another thread (simulating an in-flight
|
|
|
|
|
# second update) for 0.4 s.
|
|
|
|
|
release_time = []
|
|
|
|
|
lock_held = _th.Event()
|
|
|
|
|
|
|
|
|
|
def holder():
|
|
|
|
|
with upd._apply_lock:
|
|
|
|
|
lock_held.set()
|
|
|
|
|
_t.sleep(0.4)
|
|
|
|
|
release_time.append(_t.monotonic())
|
|
|
|
|
|
|
|
|
|
holder_thread = _th.Thread(target=holder, daemon=True)
|
|
|
|
|
holder_thread.start()
|
|
|
|
|
lock_held.wait(timeout=2)
|
|
|
|
|
|
|
|
|
|
# Schedule a restart with a short delay. The lock is held;
|
|
|
|
|
# the restart thread should block on it.
|
|
|
|
|
upd._schedule_restart(delay=0.05)
|
|
|
|
|
_t.sleep(0.15)
|
|
|
|
|
assert not execv_called.is_set(), (
|
|
|
|
|
"execv called while _apply_lock was still held by another "
|
|
|
|
|
"thread — restart must wait for in-flight updates to finish"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Let the holder release.
|
|
|
|
|
holder_thread.join(timeout=2)
|
|
|
|
|
assert release_time, "holder didn't release the lock"
|
|
|
|
|
|
|
|
|
|
# execv should fire shortly after the lock release.
|
|
|
|
|
assert execv_called.wait(timeout=2), (
|
|
|
|
|
"execv never fired after _apply_lock was released"
|
|
|
|
|
)
|
|
|
|
|
assert execv_time[0] >= release_time[0], (
|
|
|
|
|
f"execv fired before lock was released "
|
|
|
|
|
f"(execv={execv_time[0]}, release={release_time[0]})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_schedule_restart_still_fires_when_no_update_in_flight(self, monkeypatch):
|
|
|
|
|
"""Sanity: with nothing holding the lock, restart still fires promptly."""
|
|
|
|
|
import api.updates as upd
|
|
|
|
|
import time as _t
|
|
|
|
|
|
|
|
|
|
execv_called = []
|
|
|
|
|
def fake_execv(exe, args):
|
|
|
|
|
execv_called.append(True)
|
|
|
|
|
monkeypatch.setattr(os, 'execv', fake_execv)
|
|
|
|
|
|
|
|
|
|
upd._schedule_restart(delay=0.05)
|
|
|
|
|
_t.sleep(0.25)
|
|
|
|
|
assert execv_called, (
|
|
|
|
|
"restart must still fire when _apply_lock is free"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 05:45:45 +00:00
|
|
|
|
|
|
|
|
class TestUpdateCompareSource:
|
|
|
|
|
def test_simulated_update_check_payload_includes_both_safe_compare_urls(self):
|
|
|
|
|
src = read('api/routes.py')
|
|
|
|
|
assert '"repo_url": "https://github.com/nesquena/hermes-webui"' in src
|
|
|
|
|
assert '"compare_url": "https://github.com/nesquena/hermes-webui/compare/abc1234...def5678"' in src
|
|
|
|
|
assert '"repo_url": "https://github.com/NousResearch/hermes-agent"' in src
|
|
|
|
|
assert '"compare_url": "https://github.com/NousResearch/hermes-agent/compare/aaa0001...bbb0002"' in src
|
|
|
|
|
|
|
|
|
|
def test_update_banner_html_uses_multi_target_links_container(self):
|
|
|
|
|
src = read('static/index.html')
|
|
|
|
|
assert 'id="updateWhatsNewLinks"' in src
|
|
|
|
|
assert 'id="updateWhatsNew"' not in src
|
|
|
|
|
|
|
|
|
|
def test_update_banner_frontend_uses_data_driven_compare_helpers(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
assert 'function _isSafeUpdateCompareUrl(url)' in src
|
|
|
|
|
assert 'function _updateCompareUrl(info)' in src
|
|
|
|
|
assert 'function _updateWhatsNewTargets(data)' in src
|
|
|
|
|
assert 'function _renderUpdateWhatsNewLinks(data)' in src
|
|
|
|
|
assert "$('updateWhatsNewLinks')" in src
|
|
|
|
|
assert "compare_url" in src
|
|
|
|
|
assert "repo_url+'/compare/'+currentSha+'...'+latestSha" in src
|
|
|
|
|
assert "_isSafeUpdateCompareUrl(compareUrl)?compareUrl:null" in src
|
|
|
|
|
assert "_renderUpdateWhatsNewLinks(data);" in src
|
|
|
|
|
assert "data.webui.repo_url" not in src
|
|
|
|
|
assert "$('updateWhatsNew')" not in src
|
|
|
|
|
|
|
|
|
|
def test_update_banner_clears_stale_links_when_no_updates_remain(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
start = src.find('function _showUpdateBanner(data)')
|
|
|
|
|
assert start != -1, "_showUpdateBanner not found"
|
|
|
|
|
fn = src[start:src.find('function dismissUpdate()', start)]
|
|
|
|
|
empty_idx = fn.find('if(!parts.length)')
|
|
|
|
|
assert empty_idx != -1, "_showUpdateBanner must handle empty update payloads"
|
|
|
|
|
empty_block = fn[empty_idx:fn.find('return;', empty_idx) + len('return;')]
|
|
|
|
|
assert '_renderUpdateWhatsNewLinks(data);' in empty_block
|
|
|
|
|
assert "classList.remove('visible')" in empty_block
|
|
|
|
|
|
|
|
|
|
def test_manual_up_to_date_check_clears_update_banner(self):
|
|
|
|
|
src = read('static/panels.js')
|
|
|
|
|
up_to_date_idx = src.find("settings_up_to_date")
|
|
|
|
|
assert up_to_date_idx != -1, "manual update up-to-date branch not found"
|
|
|
|
|
block = src[up_to_date_idx:up_to_date_idx + 300]
|
|
|
|
|
assert "_showUpdateBanner(data)" in block
|
|
|
|
|
|
|
|
|
|
|
fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:41 +00:00
|
|
|
# ── Regression: force button reset on retry ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestForceButtonResetOnRetry:
|
|
|
|
|
"""#813 UX: if a prior update attempt showed the force button (conflict),
|
|
|
|
|
the next call to applyUpdates() must reset it — otherwise a subsequent
|
|
|
|
|
non-conflict error (e.g. network) leaves the stale force button visible
|
|
|
|
|
pointing at the wrong target."""
|
|
|
|
|
|
|
|
|
|
def test_apply_updates_resets_force_button_at_start(self):
|
|
|
|
|
src = read('static/ui.js')
|
|
|
|
|
m = re.search(r'async function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
|
|
|
|
assert m, "applyUpdates() not found"
|
|
|
|
|
fn = m.group(0)
|
|
|
|
|
# The reset must appear BEFORE the main update loop, so it runs on
|
|
|
|
|
# every retry — not only on first invocation.
|
|
|
|
|
setup, _, rest = fn.partition('const targets=')
|
|
|
|
|
assert 'btnForceUpdate' in setup, (
|
|
|
|
|
"applyUpdates must reset btnForceUpdate visibility before "
|
|
|
|
|
"starting the update loop (stale conflict state otherwise "
|
|
|
|
|
"persists across retries)"
|
|
|
|
|
)
|
|
|
|
|
assert "display='none'" in setup or "display = 'none'" in setup, (
|
|
|
|
|
"applyUpdates setup must hide btnForceUpdate via display:none"
|
|
|
|
|
)
|
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── #785: Manual 'Check for Updates' button ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestCheckForUpdatesButton:
|
|
|
|
|
"""#785: Ensure the 'Check for Updates' button is wired up correctly."""
|
|
|
|
|
|
|
|
|
|
def test_checkUpdatesNow_defined_in_panels(self):
|
|
|
|
|
"""checkUpdatesNow() function must exist in panels.js."""
|
|
|
|
|
src = read('static/panels.js')
|
|
|
|
|
assert 'function checkUpdatesNow' in src or 'async function checkUpdatesNow' in src, (
|
|
|
|
|
"checkUpdatesNow() not found in panels.js"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_btnCheckUpdatesNow_in_html(self):
|
|
|
|
|
"""Button element with id='btnCheckUpdatesNow' must exist in index.html."""
|
|
|
|
|
src = read('static/index.html')
|
|
|
|
|
assert 'id="btnCheckUpdatesNow"' in src, (
|
|
|
|
|
"btnCheckUpdatesNow element not found in index.html"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_checkUpdatesBlock_css_exists(self):
|
|
|
|
|
"""CSS rules for #checkUpdatesBlock and .btn-tiny must exist in style.css."""
|
|
|
|
|
src = read('static/style.css')
|
|
|
|
|
assert '#checkUpdatesBlock' in src, (
|
|
|
|
|
"#checkUpdatesBlock CSS selector not found in style.css"
|
|
|
|
|
)
|
|
|
|
|
assert '.btn-tiny' in src, (
|
|
|
|
|
".btn-tiny CSS selector not found in style.css"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_check_now_i18n_key_exists(self):
|
|
|
|
|
"""settings_check_now i18n key must exist in all locale blocks."""
|
|
|
|
|
src = read('static/i18n.js')
|
|
|
|
|
count = src.count('settings_check_now')
|
|
|
|
|
assert count >= 5, (
|
|
|
|
|
f"settings_check_now found in only {count} locale blocks (expected ≥5: en, ru, es, zh, zh-Hant)"
|
|
|
|
|
)
|
|
|
|
|
|