feat(updates): notify when a newer GitHub release is available (#645)
* feat(updates): notify when a newer GitHub release is available Check the configured release channel shortly after startup and every six hours. When an update exists, show a tray menu item and a green New badge on Settings → About, plus one desktop notification per version, so users can open release notes and update themselves. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * refactor(updates): slim UpdateMonitor after ponytail review Drop unused APIs (tick/check_now/version injector/last_* state), integer-only GLib scheduling, and the local demo launcher. Keep tray notify-send local to avoid coupling to recognition_manager. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * fix(updates): address Bugbot and harden notify persistence Persist last_notified_version only after notify-send spawns successfully, and sync the tray Update Available item from About-page checks without re-notifying. Also merge latest main and cover the new paths in tests. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * test(tray): pin config mock for update-notification assertions CI on 3.9/3.10 showed empty MagicMock set call history after a successful notify-send spawn. Pin tray.config_manager to the test mock and record set() via side_effect so persistence checks are reliable across Python versions. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * fix(updates): clear pending About update on failed lookup A failed About check hid the New badge but left _pending_update set, so clearing settings search could revive the badge while status still showed the failed lookup. Clear dialog pending state on failure (tray unchanged) and restore the badge via _set_about_update_badge. * fix(updates): ignore stale UpdateMonitor results after channel switch Bind the channel for each background check and discard the callback when the configured channel changed mid-flight, then re-check immediately — matching About-page stale-channel handling so the tray cannot show the wrong channel's update state. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
co-authored by
Cursor Agent
Jatin K Malik
parent
4a1d3ed9df
commit
06e8809c2a
@@ -0,0 +1,133 @@
|
||||
"""Tests for the background GitHub update monitor."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.utils.update_checker import ReleaseInfo
|
||||
from vocalinux.utils.update_monitor import UpdateMonitor
|
||||
|
||||
|
||||
def _release(tag: str = "v0.99.0") -> ReleaseInfo:
|
||||
return ReleaseInfo(
|
||||
tag_name=tag,
|
||||
version=tag.lstrip("v"),
|
||||
name=tag,
|
||||
html_url=f"https://github.com/jatinkrmalik/vocalinux/releases/tag/{tag}",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateMonitor:
|
||||
def test_start_is_idempotent(self):
|
||||
monitor = UpdateMonitor(
|
||||
get_channel=lambda: "stable",
|
||||
on_result=MagicMock(),
|
||||
use_glib=False,
|
||||
)
|
||||
monitor.start()
|
||||
monitor.start()
|
||||
assert monitor._running
|
||||
monitor.stop()
|
||||
assert not monitor._running
|
||||
|
||||
def test_reports_available_update(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.99.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
),
|
||||
patch("vocalinux.utils.update_monitor.__version__", "0.15.0"),
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_called_once_with(True, release)
|
||||
monitor.stop()
|
||||
|
||||
def test_reports_up_to_date(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.15.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_called_once_with(False, None)
|
||||
monitor.stop()
|
||||
|
||||
def test_failed_fetch_keeps_prior_callback_state(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.99.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
on_result.reset_mock()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=None,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_not_called()
|
||||
monitor.stop()
|
||||
|
||||
def test_stale_generation_is_ignored(self):
|
||||
on_result = MagicMock()
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
stale = monitor._generation
|
||||
monitor._generation += 1
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=_release(),
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", stale)
|
||||
|
||||
on_result.assert_not_called()
|
||||
monitor.stop()
|
||||
|
||||
def test_stale_channel_result_is_ignored_and_rechecks(self):
|
||||
"""A result for an earlier channel must not update the tray."""
|
||||
on_result = MagicMock()
|
||||
channel = {"value": "stable"}
|
||||
monitor = UpdateMonitor(
|
||||
get_channel=lambda: channel["value"],
|
||||
on_result=on_result,
|
||||
use_glib=False,
|
||||
)
|
||||
monitor.start()
|
||||
generation = monitor._generation
|
||||
|
||||
channel["value"] = "nightly"
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=_release(),
|
||||
) as fetch_mock,
|
||||
patch.object(monitor, "_run_check") as run_check,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", generation)
|
||||
|
||||
on_result.assert_not_called()
|
||||
run_check.assert_called_once_with()
|
||||
# Worker itself should not have re-fetched for nightly — only the
|
||||
# deferred _run_check path does that.
|
||||
fetch_mock.assert_called_once_with(channel="stable")
|
||||
monitor.stop()
|
||||
Reference in New Issue
Block a user