288 lines
14 KiB
Python
288 lines
14 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
|
|
|
|
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'
|
|
|
|
|
|
def test_agent_resolution_identical_under_both_webui_channels(tmp_path, monkeypatch):
|
|
"""Codex gate: the update CHANNEL is WebUI-only. An Agent repo that tags only
|
|
plain v* must resolve release/apply IDENTICALLY whether the user's WebUI
|
|
channel is 'stable' or 'experimental' — the webui channel must never leak
|
|
into the agent check (which would make the agent ignore its v* tags and fall
|
|
back to origin/master)."""
|
|
agent = tmp_path / 'agent'
|
|
agent.mkdir()
|
|
_git(agent, 'init', '-q')
|
|
_git(agent, 'config', 'user.email', 't@t.co')
|
|
_git(agent, 'config', 'user.name', 'Test')
|
|
_git(agent, 'remote', 'add', 'origin', 'https://github.com/nesquena/hermes-agent.git')
|
|
_git(agent, 'commit', '-q', '--allow-empty', '-m', 'a1')
|
|
_git(agent, 'tag', 'v1.0.0')
|
|
_git(agent, 'commit', '-q', '--allow-empty', '-m', 'a2')
|
|
_git(agent, 'tag', 'v1.0.3') # agent has ONLY plain v* tags, no exp-v*
|
|
_git(agent, 'checkout', '-q', 'v1.0.0') # behind by one release
|
|
|
|
# check_for_updates threads the user's WebUI channel; the agent leg must
|
|
# ignore it. Compare agent payloads under stable vs experimental WebUI.
|
|
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path / 'nonexistent-webui')
|
|
monkeypatch.setattr(updates, '_AGENT_DIR', agent)
|
|
|
|
def fresh_cache():
|
|
monkeypatch.setitem(updates._update_cache, 'checked_at', 0)
|
|
|
|
fresh_cache()
|
|
stable = updates.check_for_updates(force=True, include_agent=True, channel='stable')['agent']
|
|
fresh_cache()
|
|
experimental = updates.check_for_updates(force=True, include_agent=True, channel='experimental')['agent']
|
|
|
|
# Agent must resolve its v1.0.3 release identically on both — never None /
|
|
# origin/master (which is what leaking 'experimental' into the agent caused).
|
|
assert stable.get('latest_version') == 'v1.0.3'
|
|
assert experimental.get('latest_version') == 'v1.0.3'
|
|
assert stable.get('behind') == experimental.get('behind') == 1
|
|
# Apply-ref selection for the agent is channel-independent because the apply
|
|
# wrappers force DEFAULT_UPDATE_CHANNEL for target=='agent'. Verified at the
|
|
# raw layer with the default (stable) channel — the agent's v* tag resolves.
|
|
assert updates._select_apply_compare_ref(agent, 'stable', 'agent') == 'v1.0.3'
|
|
|
|
|
|
# ── 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
|
|
|
|
|
|
# ── Lock-recovery preserves the channel (Codex round-2 SILENT) ───────────────
|
|
|
|
def test_clear_lock_retry_preserves_experimental_channel(tmp_path, monkeypatch):
|
|
"""apply_clear_lock re-runs the normal update once the lock is gone. It must
|
|
pass the configured channel through — otherwise an experimental WebUI
|
|
lock-recovery retry silently falls back to stable (_apply_update_inner
|
|
defaults to stable)."""
|
|
(tmp_path / '.git').mkdir()
|
|
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
|
|
monkeypatch.setattr(
|
|
updates, '_restart_blocker_snapshot',
|
|
lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
|
|
)
|
|
# No lock present → clear-lock takes the "re-run normal update" branch.
|
|
monkeypatch.setattr(
|
|
updates, '_inventory_locks',
|
|
lambda path: {'well_known_lock_present': False,
|
|
'well_known_lock_path': str(tmp_path / '.git/index.lock'),
|
|
'other_locks': []},
|
|
)
|
|
# User's configured channel is experimental.
|
|
monkeypatch.setattr(updates, '_read_update_channel', lambda: 'experimental')
|
|
seen = {}
|
|
|
|
def fake_inner(target, channel='stable'):
|
|
seen['channel'] = channel
|
|
return {'ok': True, 'target': target, 'channel': channel}
|
|
|
|
monkeypatch.setattr(updates, '_apply_update_inner', fake_inner)
|
|
result = updates.apply_clear_lock('webui')
|
|
assert seen.get('channel') == 'experimental', (
|
|
'clear-lock retry must preserve the experimental channel, not default to stable'
|
|
)
|
|
assert result['ok'] is True
|
|
assert result['lock_recovery']['action'] == 'no-lock-found'
|