Files
hermes-webui/tests/test_update_channels.py
T
t 2cf43527a3 feat(updates): stable/experimental release channels
Add an update_channel setting (stable|experimental) that selects which git-tag
stream the self-updater tracks on the single linear master line:

  stable       -> 'v*'      promoted, soaked releases (default; unchanged glob)
  experimental -> 'exp-v*'  every release batch (opt-in testers)

A channel is only a tag glob — no branches, no divergence — so every ff-only
guarantee (#2653/#2846/#3140) is preserved. Channel governs the WebUI repo only;
the Agent repo keeps its historical branch fall-through.

Correctness (advisor-reviewed, Codex + Fable):
- describe uses --match <glob> so a commit tagged both v* and exp-v* resolves
  to the channel-correct tag
- stable never falls through to origin/master when HEAD contains the latest
  stable tag (the firehose-suppression that makes channels work)
- apply_force_update refuses to reset --hard onto an ancestor ref (rewind guard)
- update cache + in-progress guard keyed by (channel, include_agent)
- channel display badge is a SEPARATE field; WEBUI_VERSION stays channel-neutral
  (asset cache-busting / SW cache / stale-client skew all do exact-string equality)

Settings UX: channel dropdown with risk-forward copy + the ff-only asymmetry
helper ('switching back to Stable keeps your current version until Stable catches
up'), channel chip on the version badge, i18n keys, re-check on switch.

Tests: new tests/test_update_channels.py (12 real-git-fixture tests) + existing
update suite updated for the channel-aware signatures.
2026-07-08 19:25:27 +00:00

210 lines
9.8 KiB
Python

"""End-to-end tests for update channels (stable vs experimental).
These use REAL git repositories in tmp_path (not mocked _run_git) so they
exercise the actual git commands the channel logic depends on — the tag globs,
`describe --match`, and the ancestor/descendant merge-base checks. This is the
durable regression proof for the two-tag-channel design:
stable -> 'v*' promoted, soaked releases (default)
experimental -> 'exp-v*' every batch (opt-in testers)
Both tag families live on the SAME linear master line; a channel is only a tag
glob. The critical property under test: a STABLE user with an unpromoted
`exp-v*` tag ahead of them reports up-to-date, NOT the master firehose.
"""
import subprocess
from pathlib import Path
import pytest
import api.updates as updates
def _git(repo, *args):
subprocess.run(
['git', *args], cwd=str(repo), check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
@pytest.fixture
def channel_repo(tmp_path):
"""A linear repo: 5 batch commits, each tagged exp-v0.52.N; stable promoted
at batch 2 (v0.52.2) and batch 5 (v0.52.5). HEAD ends on batch 5."""
repo = tmp_path / 'repo'
repo.mkdir()
_git(repo, 'init', '-q')
_git(repo, 'config', 'user.email', 't@t.co')
_git(repo, 'config', 'user.name', 'Test')
_git(repo, 'remote', 'add', 'origin', 'https://github.com/nesquena/hermes-webui.git')
for i in range(1, 6):
_git(repo, 'commit', '-q', '--allow-empty', '-m', f'batch{i}')
_git(repo, 'tag', f'exp-v0.52.{i}')
# Promote batch 2 and batch 5 to stable (same commit, same version number).
_git(repo, 'tag', 'v0.52.2', 'HEAD~3')
_git(repo, 'tag', 'v0.52.5', 'HEAD')
return repo
# ── Tag-glob separation ──────────────────────────────────────────────────────
def test_stable_glob_excludes_exp_tags(channel_repo):
tags = updates._release_tags(channel_repo, 'stable')
assert tags == ['v0.52.5', 'v0.52.2']
assert all(not t.startswith('exp-') for t in tags)
def test_experimental_glob_lists_only_exp_tags(channel_repo):
tags = updates._release_tags(channel_repo, 'experimental')
assert tags == [f'exp-v0.52.{i}' for i in (5, 4, 3, 2, 1)]
def test_default_channel_is_stable(channel_repo):
# No explicit channel == stable glob.
assert updates._release_tags(channel_repo) == updates._release_tags(channel_repo, 'stable')
def test_unknown_channel_falls_back_to_stable(channel_repo):
assert updates._normalize_channel('bogus') == 'stable'
assert updates._normalize_channel(None) == 'stable'
assert updates._release_tags(channel_repo, 'bogus') == updates._release_tags(channel_repo, 'stable')
# ── current_release_tag is channel-scoped (Codex CORE #1) ────────────────────
def test_current_release_tag_matches_channel(channel_repo):
# HEAD is tagged BOTH v0.52.5 and exp-v0.52.5. describe must pick the
# channel-matched tag, not whichever git prefers lexically.
assert updates._current_release_tag(channel_repo, 'stable') == 'v0.52.5'
assert updates._current_release_tag(channel_repo, 'experimental') == 'exp-v0.52.5'
# ── The core property: stable never sees the firehose (Codex CORE #2) ────────
def test_stable_user_behind_a_promoted_tag_sees_the_update(channel_repo):
"""Stable user sitting on v0.52.2 must be offered v0.52.5 (a real gap)."""
_git(channel_repo, 'checkout', '-q', 'v0.52.2')
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
assert info is not None
assert info['behind'] == 1
assert info['current_version'] == 'v0.52.2'
assert info['latest_version'] == 'v0.52.5'
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') == 'v0.52.5'
def test_stable_user_on_latest_stable_with_unpromoted_exp_ahead_is_up_to_date(channel_repo):
"""THE key guarantee: a stable webui user on v0.52.5 with an unpromoted
exp-v0.52.6 ahead of them reports UP-TO-DATE, never the master firehose."""
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
_git(channel_repo, 'tag', 'exp-v0.52.6') # experimental-only, NOT promoted
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
assert info is not None
assert info['behind'] == 0, 'stable must be up-to-date, not offered the firehose'
# Apply side agrees: it resolves to the current stable tag (a no-op ff),
# NEVER to origin/master. Both "current tag" and None mean up-to-date; the
# firehose ref (origin/*) must never appear.
apply_ref = updates._select_apply_compare_ref(channel_repo, 'stable', 'webui')
assert apply_ref in ('v0.52.5', None)
assert apply_ref is None or not apply_ref.startswith('origin/')
def test_stable_user_PAST_latest_stable_on_untagged_commit_is_up_to_date(channel_repo):
"""A stable webui user whose HEAD moved PAST the latest stable tag onto an
unpromoted (untagged-on-stable) commit must report up-to-date, NOT fall
through to origin/master. This is the exact firehose-suppression path."""
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past-stable')
_git(channel_repo, 'tag', 'exp-v0.52.6') # exp-only; no stable tag here
# HEAD is now one commit PAST v0.52.5 with no stable tag on it.
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
assert info is not None
assert info['behind'] == 0, 'stable past-tag must be up-to-date, not firehose'
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') is None
def test_experimental_user_sees_unpromoted_exp_commit(channel_repo):
"""Same HEAD (v0.52.5), experimental channel, sees exp-v0.52.6 as behind=1."""
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
_git(channel_repo, 'tag', 'exp-v0.52.6')
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
info = updates._check_repo_release(channel_repo, 'webui', 'experimental')
assert info is not None
assert info['behind'] == 1
assert info['current_version'] == 'exp-v0.52.5'
assert info['latest_version'] == 'exp-v0.52.6'
# ── Agent repo keeps historical fall-through (channel is webui-only) ─────────
def test_agent_repo_falls_through_to_branch_even_on_stable(channel_repo):
"""The agent repo legitimately tracks master past its tags — the stable
channel's fall-through suppression is webui-only, so the agent still
branch-compares when HEAD is past the latest tag."""
# Add commits past the latest stable tag WITHOUT promoting them, and set an
# upstream so the branch path is reachable.
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past1')
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past2')
# name='agent' → suppression OFF → past-tag HEAD returns None (fall through).
result = updates._check_repo_release(channel_repo, 'agent', 'stable')
assert result is None, 'agent must fall through to branch check when past its tag'
# ── Force-update rewind guard (Codex CORE #3) ────────────────────────────────
def test_force_update_refuses_rewind_when_ref_is_ancestor(channel_repo, monkeypatch):
"""The rewind guard: apply_force_update must refuse to reset --hard onto a
ref that is a strict ANCESTOR of HEAD (a downgrade). HEAD is on v0.52.5;
we force to a ref resolving to the older v0.52.2 (an ancestor)."""
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
monkeypatch.setattr(
updates, '_restart_blocker_snapshot',
lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
)
# Force the compare ref to the older stable tag (a strict ancestor of HEAD).
monkeypatch.setattr(
updates, '_select_apply_compare_ref',
lambda path, channel='stable', target=None: 'v0.52.2',
)
real_run_git = updates._run_git
def no_fetch(args, cwd, timeout=10):
if args[:2] == ['fetch', 'origin']:
return '', True
return real_run_git(args, cwd, timeout=timeout)
monkeypatch.setattr(updates, '_run_git', no_fetch)
result = updates.apply_force_update('webui', channel='stable')
assert result.get('refused_rewind') is True, result
assert result['ok'] is False
# HEAD must not have moved (no rewind actually happened).
head, ok = updates._run_git(['rev-parse', 'HEAD'], channel_repo)
v525, _ = updates._run_git(['rev-parse', 'v0.52.5'], channel_repo)
assert head == v525, 'force-update must not have rewound HEAD'
# ── Cache is keyed by channel (Codex SILENT #5) ──────────────────────────────
def test_update_cache_scoped_by_channel(channel_repo, monkeypatch):
calls = []
def fake_check_repo(path, name, channel='stable'):
calls.append((name, channel))
return {'name': name, 'behind': 0, 'channel': channel}
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
monkeypatch.setattr(updates, '_AGENT_DIR', channel_repo)
monkeypatch.setattr(updates, '_check_repo', fake_check_repo)
monkeypatch.setitem(updates._update_cache, 'checked_at', 0)
monkeypatch.setitem(updates._update_cache, 'channel', 'stable')
stable = updates.check_for_updates(force=True, include_agent=False, channel='stable')
# A non-forced call on a DIFFERENT channel must not serve the stable cache.
experimental = updates.check_for_updates(force=False, include_agent=False, channel='experimental')
assert stable['channel'] == 'stable'
assert experimental['channel'] == 'experimental'
# Both channels triggered a real check (no stale cross-channel cache hit).
assert ('webui', 'stable') in calls
assert ('webui', 'experimental') in calls