CUDA toolkit detection also checks CUDA_HOME, CUDA_PATH and /usr/local/cuda*, and the backend order names only backends the installed build compiled in. An explicit IBus pin on niri is honoured with one warning. The update checker queries this fork's Gitea releases and packaged installs make no network request. The one-time legacy import repoints a migrated dictionary path at the copied file.
407 lines
16 KiB
Python
407 lines
16 KiB
Python
"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
|
|
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from vocalinux.utils.update_checker import (
|
|
ReleaseInfo,
|
|
build_channel,
|
|
fetch_latest_release,
|
|
format_release_notes,
|
|
is_newer_version,
|
|
is_trusted_release_url,
|
|
is_update_available,
|
|
normalize_channel,
|
|
normalize_version,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _unpackaged_install(monkeypatch):
|
|
"""Keep these tests independent of a packaged marker on the host (ticket #27)."""
|
|
monkeypatch.setattr("vocalinux.utils.update_checker.is_packaged_install", lambda: False)
|
|
|
|
|
|
class TestNormalizeAndCompare:
|
|
def test_normalize_version_strips_v_prefix(self):
|
|
assert normalize_version("v0.15.0") == "0.15.0"
|
|
assert normalize_version("V0.15.0") == "0.15.0"
|
|
|
|
def test_equal_versions_are_not_newer(self):
|
|
assert not is_newer_version("0.15.0", "0.15.0")
|
|
assert not is_newer_version("v0.15.0", "0.15.0")
|
|
|
|
def test_numeric_ordering(self):
|
|
assert is_newer_version("0.9.0", "0.10.0")
|
|
assert is_newer_version("0.15.0", "0.15.1")
|
|
assert not is_newer_version("1.0.0", "0.99.0")
|
|
|
|
def test_stable_outranks_prerelease(self):
|
|
assert is_newer_version("0.15.0-beta", "0.15.0")
|
|
assert not is_newer_version("0.15.0", "0.15.0-beta")
|
|
|
|
def test_dot_prerelease_suffix(self):
|
|
assert is_newer_version("0.15.0.rc1", "0.15.0")
|
|
assert not is_newer_version("0.15.0", "0.15.0.rc1")
|
|
|
|
def test_build_metadata_ignored(self):
|
|
assert not is_newer_version("0.15.0+flatpak", "0.15.0")
|
|
|
|
|
|
class TestChannels:
|
|
def test_build_channel_from_version(self) -> None:
|
|
cases = (
|
|
("0.18.3", "stable"),
|
|
("v0.18.3", "stable"),
|
|
("0.18.3+flatpak", "stable"),
|
|
("0.14.0-beta", "stable"),
|
|
("0.18.3.dev20261020+4799f06", "nightly"),
|
|
("0.18.3.dev20261020", "nightly"),
|
|
)
|
|
for version, expected in cases:
|
|
assert build_channel(version) == expected
|
|
|
|
def test_build_channel_reads_version_module_at_call_time(self) -> None:
|
|
with patch(
|
|
"vocalinux.version.__version__",
|
|
"0.18.3.dev20261020+4799f06",
|
|
):
|
|
assert build_channel() == "nightly"
|
|
|
|
def test_normalize_channel(self):
|
|
assert normalize_channel("stable") == "stable"
|
|
assert normalize_channel("NIGHTLY") == "nightly"
|
|
assert normalize_channel("nope") == "stable"
|
|
|
|
def test_stable_update_uses_semver(self):
|
|
release = ReleaseInfo(
|
|
tag_name="v0.15.0",
|
|
version="0.15.0",
|
|
name="v0.15.0",
|
|
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
|
body="",
|
|
published_at="2026-08-02T00:00:00Z",
|
|
prerelease=False,
|
|
channel="stable",
|
|
)
|
|
assert is_update_available("0.14.0", release, "stable")
|
|
assert not is_update_available("0.15.0", release, "stable")
|
|
|
|
def test_nightly_update_compares_dates(self):
|
|
release = ReleaseInfo(
|
|
tag_name="nightly-2026-08-02",
|
|
version="nightly-2026-08-02",
|
|
name="Nightly Build - 0.14.2.dev20260802+27ecf88",
|
|
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
|
body="",
|
|
published_at="2026-08-02T00:00:00Z",
|
|
prerelease=True,
|
|
channel="nightly",
|
|
)
|
|
assert is_update_available("nightly-2026-08-01", release, "nightly")
|
|
assert not is_update_available("nightly-2026-08-02", release, "nightly")
|
|
# Installed nightlies use .devYYYYMMDD[+sha], not the GitHub tag name.
|
|
assert not is_update_available("0.14.2.dev20260802+27ecf88", release, "nightly")
|
|
assert is_update_available("0.14.2.dev20260801+aaaaaaa", release, "nightly")
|
|
# Stable installs following nightly should be offered the latest nightly.
|
|
assert is_update_available("0.15.0", release, "nightly")
|
|
|
|
|
|
class TestTrustedUrls:
|
|
def test_accepts_project_urls(self):
|
|
assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
|
|
assert is_trusted_release_url(
|
|
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
|
|
)
|
|
|
|
def test_rejects_upstream_and_other_urls(self):
|
|
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
|
|
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
|
|
assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
|
|
assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
|
|
assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
|
|
assert not is_trusted_release_url("")
|
|
|
|
def test_rejects_non_https_urls(self):
|
|
assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
|
|
|
|
|
|
class TestFormatReleaseNotes:
|
|
def test_empty_body(self):
|
|
assert "No release notes" in format_release_notes("")
|
|
|
|
def test_keeps_code_block_contents(self):
|
|
body = "Install with:\n```bash\ncurl -fsSL https://example.com | bash\n```\nDone"
|
|
notes = format_release_notes(body)
|
|
assert "curl -fsSL https://example.com | bash" in notes
|
|
assert "```" not in notes
|
|
|
|
def test_strips_images_and_keeps_links(self):
|
|
body = 'See [docs](https://example.com) <img src="x.png" alt="x" />'
|
|
notes = format_release_notes(body)
|
|
assert "docs (https://example.com)" in notes
|
|
assert "<img" not in notes
|
|
|
|
|
|
class TestFetchLatestRelease:
|
|
def test_returns_none_on_network_error(self):
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.side_effect = Exception("offline")
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
assert fetch_latest_release() is None
|
|
|
|
def test_parses_release_payload(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"tag_name": "v0.15.0",
|
|
"name": "v0.15.0",
|
|
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
|
"body": "Notes",
|
|
"published_at": "2026-08-02T22:29:58Z",
|
|
"prerelease": False,
|
|
}
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release()
|
|
|
|
assert isinstance(release, ReleaseInfo)
|
|
assert release.version == "0.15.0"
|
|
assert release.html_url.endswith("/v0.15.0")
|
|
mock_requests.get.assert_called_once()
|
|
api_url = mock_requests.get.call_args[0][0]
|
|
assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
|
|
|
def test_drops_untrusted_html_url(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"tag_name": "v0.15.0",
|
|
"name": "v0.15.0",
|
|
"html_url": "https://evil.example/download",
|
|
"body": "Notes",
|
|
"published_at": "2026-08-02T22:29:58Z",
|
|
"prerelease": False,
|
|
}
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release()
|
|
|
|
assert release is not None
|
|
assert release.html_url == ""
|
|
|
|
def test_nightly_channel_picks_nightly_tag(self):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = [
|
|
{
|
|
"tag_name": "v0.15.0",
|
|
"name": "v0.15.0",
|
|
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
|
"body": "Stable",
|
|
"published_at": "2026-08-02T22:29:58Z",
|
|
"prerelease": False,
|
|
"draft": False,
|
|
},
|
|
{
|
|
"tag_name": "nightly-2026-08-02",
|
|
"name": "nightly-2026-08-02",
|
|
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
|
"body": "Nightly",
|
|
"published_at": "2026-08-02T05:00:00Z",
|
|
"prerelease": True,
|
|
"draft": False,
|
|
},
|
|
]
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release(channel="nightly")
|
|
|
|
assert release is not None
|
|
assert release.tag_name == "nightly-2026-08-02"
|
|
assert release.channel == "nightly"
|
|
mock_requests.get.assert_called_once()
|
|
assert mock_requests.get.call_args[0][0].endswith("/releases")
|
|
|
|
def test_nightly_channel_pages_past_non_nightly_releases(self):
|
|
"""Newest nightly can sit beyond the first /releases page."""
|
|
page1 = [
|
|
{
|
|
"tag_name": f"v0.15.{i}",
|
|
"name": f"v0.15.{i}",
|
|
"html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
|
|
"body": "",
|
|
"published_at": "2026-08-03T00:00:00Z",
|
|
"prerelease": False,
|
|
"draft": False,
|
|
}
|
|
for i in range(30, 0, -1)
|
|
]
|
|
page2 = [
|
|
{
|
|
"tag_name": "nightly-2026-08-01",
|
|
"name": "nightly-2026-08-01",
|
|
"html_url": (
|
|
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
|
|
),
|
|
"body": "Nightly",
|
|
"published_at": "2026-08-01T05:00:00Z",
|
|
"prerelease": True,
|
|
"draft": False,
|
|
}
|
|
]
|
|
responses = []
|
|
for payload in (page1, page2):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = payload
|
|
responses.append(mock_response)
|
|
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.side_effect = responses
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release(channel="nightly")
|
|
|
|
assert release is not None
|
|
assert release.tag_name == "nightly-2026-08-01"
|
|
assert mock_requests.get.call_count == 2
|
|
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
|
|
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
|
|
|
|
def test_stable_404_means_no_release_and_warns(self, caplog):
|
|
"""A missing or private repo answers 404 anonymously: no release, one warning."""
|
|
mock_requests = _requests_mock([_mock_response(404)])
|
|
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
assert fetch_latest_release() is None
|
|
mock_requests.get.assert_called_once()
|
|
assert len(_checker_warnings(caplog)) == 1
|
|
|
|
@pytest.mark.parametrize("status_code", [403, 429])
|
|
def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
|
|
mock_requests = _requests_mock([_mock_response(status_code)])
|
|
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
assert fetch_latest_release() is None
|
|
mock_requests.get.assert_called_once()
|
|
assert _checker_warnings(caplog) == []
|
|
|
|
def test_prerelease_tag_is_compared_not_filtered(self):
|
|
"""Existing comparison rules apply to a pre-release tag: no new filter."""
|
|
payload = {
|
|
"tag_name": "v0.18.0-beta.1",
|
|
"name": "v0.18.0-beta.1",
|
|
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
|
|
"body": "Beta",
|
|
"published_at": "2026-08-02T22:29:58Z",
|
|
"prerelease": True,
|
|
}
|
|
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release()
|
|
|
|
assert release is not None
|
|
assert release.prerelease is True
|
|
assert release.version == "0.18.0-beta.1"
|
|
assert is_update_available("0.17.0", release) is True
|
|
assert is_update_available("0.18.0", release) is False
|
|
|
|
def test_nightly_api_403_returns_none(self, caplog):
|
|
api_response = _mock_response(403)
|
|
mock_requests = _requests_mock([api_response])
|
|
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
assert fetch_latest_release(channel="nightly") is None
|
|
mock_requests.get.assert_called_once()
|
|
requested = mock_requests.get.call_args[0][0]
|
|
assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
|
|
assert _checker_warnings(caplog) == []
|
|
|
|
def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
|
|
"""The stable lookup reads Gitea's JSON and never contacts GitHub."""
|
|
payload = {
|
|
"tag_name": "v0.18.6",
|
|
"name": "v0.18.6",
|
|
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
|
|
"body": "Notes",
|
|
"published_at": "2026-11-06T10:00:00Z",
|
|
"prerelease": False,
|
|
}
|
|
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
release = fetch_latest_release()
|
|
|
|
assert release is not None
|
|
assert release.tag_name == "v0.18.6"
|
|
assert release.html_url == payload["html_url"]
|
|
assert release.body == "Notes"
|
|
mock_requests.get.assert_called_once()
|
|
first = mock_requests.get.call_args
|
|
assert (
|
|
first[0][0]
|
|
== "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
|
)
|
|
assert first.kwargs["headers"]["Accept"] == "application/json"
|
|
assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
|
|
assert "Authorization" not in first.kwargs["headers"]
|
|
|
|
@pytest.mark.parametrize("channel", ["stable", "nightly"])
|
|
def test_source_install_never_queries_upstream(self, channel):
|
|
payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
|
|
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
fetch_latest_release(channel=channel)
|
|
for call in mock_requests.get.call_args_list:
|
|
assert call[0][0].startswith(
|
|
"https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
|
|
)
|
|
|
|
def test_packaged_install_makes_no_network_request(self):
|
|
"""Packaged installs get updates from xbps, so the checker stays off the network."""
|
|
mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
|
|
with (
|
|
patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
|
|
patch.dict("sys.modules", {"requests": mock_requests}),
|
|
):
|
|
assert fetch_latest_release() is None
|
|
assert fetch_latest_release(channel="nightly") is None
|
|
mock_requests.get.assert_not_called()
|
|
|
|
|
|
def _mock_response(status_code, json_value=None, url=""):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = status_code
|
|
mock_response.url = url
|
|
mock_response.json.return_value = json_value
|
|
return mock_response
|
|
|
|
|
|
def _requests_mock(side_effect):
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.side_effect = side_effect
|
|
mock_requests.exceptions.RequestException = Exception
|
|
return mock_requests
|
|
|
|
|
|
def _checker_warnings(caplog):
|
|
return [
|
|
record
|
|
for record in caplog.records
|
|
if record.name == "vocalinux.utils.update_checker" and record.levelno >= logging.WARNING
|
|
]
|