Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
1254 lines
52 KiB
Python
1254 lines
52 KiB
Python
"""Tests for auto-update functionality."""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from pathlib import Path
|
|
|
|
from desktop_app.updater import (
|
|
check_for_updates,
|
|
parse_version,
|
|
get_platform_asset_name,
|
|
get_last_installed_asset_id,
|
|
save_installed_asset_id,
|
|
UpdateChannel,
|
|
UpdateStatus,
|
|
ReleaseInfo,
|
|
_escape_applescript_path,
|
|
_escape_batch_path,
|
|
_escape_shell_path,
|
|
)
|
|
|
|
|
|
def _zipfile_extract_for_tests(zip_path: Path, dest_dir: Path) -> None:
|
|
"""Stand-in for ``_extract_macos_bundle`` used by existing unit tests.
|
|
|
|
Production code uses ``ditto`` (a subprocess call), but tests mock
|
|
``subprocess.Popen`` which also breaks ``subprocess.run``. Swapping in a
|
|
direct zipfile extraction lets the existing tests run their assertions
|
|
on the generated shell script without the ditto invocation.
|
|
"""
|
|
import zipfile
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
zf.extractall(dest_dir)
|
|
|
|
|
|
class TestParseVersion:
|
|
"""Tests for version parsing."""
|
|
|
|
@pytest.mark.unit
|
|
def test_parses_semver_with_v_prefix(self):
|
|
assert parse_version("v1.2.3") == (1, 2, 3)
|
|
|
|
@pytest.mark.unit
|
|
def test_parses_semver_without_prefix(self):
|
|
assert parse_version("1.2.3") == (1, 2, 3)
|
|
|
|
@pytest.mark.unit
|
|
def test_handles_latest_tag(self):
|
|
assert parse_version("latest") == (0, 0, 0)
|
|
|
|
@pytest.mark.unit
|
|
def test_compares_patch_versions(self):
|
|
assert parse_version("v1.2.0") < parse_version("v1.2.1")
|
|
|
|
@pytest.mark.unit
|
|
def test_compares_major_versions(self):
|
|
assert parse_version("v2.0.0") > parse_version("v1.9.9")
|
|
|
|
@pytest.mark.unit
|
|
def test_compares_minor_versions(self):
|
|
assert parse_version("v1.3.0") > parse_version("v1.2.9")
|
|
|
|
@pytest.mark.unit
|
|
def test_handles_invalid_version(self):
|
|
assert parse_version("invalid") == (0, 0, 0)
|
|
|
|
|
|
class TestGetPlatformAssetName:
|
|
"""Tests for platform asset name detection."""
|
|
|
|
@pytest.mark.unit
|
|
def test_macos_arm64(self):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
assert get_platform_asset_name() == "Jarvis-macOS-arm64.zip"
|
|
|
|
@pytest.mark.unit
|
|
def test_macos_x64(self):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="x86_64"):
|
|
assert get_platform_asset_name() == "Jarvis-macOS-x64.zip"
|
|
|
|
@pytest.mark.unit
|
|
def test_windows(self):
|
|
with patch("sys.platform", "win32"):
|
|
assert get_platform_asset_name() == "Jarvis-Windows-x64.zip"
|
|
|
|
@pytest.mark.unit
|
|
def test_linux(self):
|
|
with patch("sys.platform", "linux"):
|
|
assert get_platform_asset_name() == "Jarvis-Linux-x64.tar.gz"
|
|
|
|
|
|
class TestCheckForUpdates:
|
|
"""Tests for update checking."""
|
|
|
|
@pytest.mark.unit
|
|
def test_returns_no_update_when_current_version_matches(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "v1.0.0",
|
|
"name": "v1.0.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/v1.0.0",
|
|
"body": "Release notes",
|
|
"assets": [
|
|
{
|
|
"id": 100001,
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is False
|
|
assert status.current_version == "1.0.0"
|
|
|
|
@pytest.mark.unit
|
|
def test_returns_update_when_newer_version_available(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "v1.1.0",
|
|
"name": "v1.1.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/v1.1.0",
|
|
"body": "Release notes",
|
|
"assets": [
|
|
{
|
|
"id": 100002,
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is True
|
|
assert status.latest_release is not None
|
|
assert status.latest_release.version == "1.1.0"
|
|
|
|
@pytest.mark.unit
|
|
def test_skips_prereleases_for_stable_channel(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "latest",
|
|
"name": "Latest Development Build",
|
|
"draft": False,
|
|
"prerelease": True,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/latest",
|
|
"body": "Dev release notes",
|
|
"assets": [
|
|
{
|
|
"id": 100003,
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
# Should not find updates because only prerelease is available
|
|
# and we're on stable channel
|
|
assert status.update_available is False
|
|
|
|
@pytest.mark.unit
|
|
def test_skips_drafts(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "v2.0.0",
|
|
"name": "v2.0.0",
|
|
"draft": True, # Draft release
|
|
"prerelease": False,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/v2.0.0",
|
|
"body": "Release notes",
|
|
"assets": [
|
|
{
|
|
"id": 100004,
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
# Should not find updates because only draft is available
|
|
assert status.update_available is False
|
|
|
|
@pytest.mark.unit
|
|
def test_handles_network_error(self):
|
|
import requests
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch(
|
|
"requests.get", side_effect=requests.RequestException("Network error")
|
|
):
|
|
status = check_for_updates()
|
|
assert status.update_available is False
|
|
assert status.error is not None
|
|
assert "Network error" in status.error
|
|
|
|
@pytest.mark.unit
|
|
def test_handles_missing_platform_asset(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "v1.1.0",
|
|
"name": "v1.1.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/v1.1.0",
|
|
"body": "Release notes",
|
|
"assets": [
|
|
{
|
|
"id": 100005,
|
|
"name": "Jarvis-Windows-x64.zip", # Only Windows asset
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"): # On macOS
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
# No macOS asset available
|
|
assert status.update_available is False
|
|
|
|
@pytest.mark.unit
|
|
def test_develop_channel_shows_update_when_no_previous_install(self):
|
|
"""Develop channel should show update when no previous install is recorded."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "latest",
|
|
"name": "Latest Development Build",
|
|
"draft": False,
|
|
"prerelease": True,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/latest",
|
|
"body": "Dev release notes",
|
|
"assets": [
|
|
{
|
|
"id": 200001,
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("dev-abc1234", "develop")):
|
|
with patch("desktop_app.updater.get_last_installed_asset_id", return_value=None):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is True
|
|
assert status.latest_release.asset_id == 200001
|
|
assert status.releases_since_current == [status.latest_release]
|
|
|
|
@pytest.mark.unit
|
|
def test_develop_channel_shows_update_when_asset_id_differs(self):
|
|
"""Develop channel should show update when asset ID differs from last install."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "latest",
|
|
"name": "Latest Development Build",
|
|
"draft": False,
|
|
"prerelease": True,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/latest",
|
|
"body": "Dev release notes",
|
|
"assets": [
|
|
{
|
|
"id": 200002, # New asset ID
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("dev-abc1234", "develop")):
|
|
with patch("desktop_app.updater.get_last_installed_asset_id", return_value=200001): # Old ID
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is True
|
|
|
|
@pytest.mark.unit
|
|
def test_develop_channel_no_update_when_asset_id_matches(self):
|
|
"""Develop channel should NOT show update when asset ID matches last install."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 12345,
|
|
"tag_name": "latest",
|
|
"name": "Latest Development Build",
|
|
"draft": False,
|
|
"prerelease": True,
|
|
"html_url": "https://github.com/isair/jarvis/releases/tag/latest",
|
|
"body": "Dev release notes",
|
|
"assets": [
|
|
{
|
|
"id": 200001, # Same asset ID as last install
|
|
"name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/download",
|
|
"size": 1000,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("dev-abc1234", "develop")):
|
|
with patch("desktop_app.updater.get_last_installed_asset_id", return_value=200001): # Same ID
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is False
|
|
|
|
|
|
class TestUpdateStatus:
|
|
"""Tests for UpdateStatus dataclass."""
|
|
|
|
@pytest.mark.unit
|
|
def test_update_status_fields(self):
|
|
release = ReleaseInfo(
|
|
asset_id=100001,
|
|
tag_name="v1.0.0",
|
|
version="1.0.0",
|
|
name="Version 1.0.0",
|
|
prerelease=False,
|
|
html_url="https://example.com",
|
|
download_url="https://example.com/download",
|
|
asset_name="Jarvis-macOS-arm64.zip",
|
|
asset_size=1000000,
|
|
release_notes="Test notes",
|
|
)
|
|
status = UpdateStatus(
|
|
update_available=True,
|
|
current_version="0.9.0",
|
|
current_channel="stable",
|
|
latest_release=release,
|
|
)
|
|
assert status.update_available is True
|
|
assert status.current_version == "0.9.0"
|
|
assert status.latest_release.version == "1.0.0"
|
|
|
|
@pytest.mark.unit
|
|
def test_releases_since_current_defaults_to_empty_list(self):
|
|
status = UpdateStatus(
|
|
update_available=False,
|
|
current_version="1.0.0",
|
|
current_channel="stable",
|
|
latest_release=None,
|
|
)
|
|
assert status.releases_since_current == []
|
|
|
|
@pytest.mark.unit
|
|
def test_collects_all_releases_since_current_version(self):
|
|
"""Stable channel should return every release newer than the installed version."""
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 3,
|
|
"tag_name": "v1.3.0",
|
|
"name": "v1.3.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://example.com/v1.3.0",
|
|
"body": "* feat: feature three (#3)",
|
|
"assets": [{"id": 300, "name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/dl3", "size": 1000}],
|
|
},
|
|
{
|
|
"id": 2,
|
|
"tag_name": "v1.2.0",
|
|
"name": "v1.2.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://example.com/v1.2.0",
|
|
"body": "* fix: bug two (#2)",
|
|
"assets": [{"id": 200, "name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/dl2", "size": 1000}],
|
|
},
|
|
{
|
|
"id": 1,
|
|
"tag_name": "v1.0.0",
|
|
"name": "v1.0.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://example.com/v1.0.0",
|
|
"body": "* Initial release",
|
|
"assets": [{"id": 100, "name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/dl1", "size": 1000}],
|
|
},
|
|
]
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is True
|
|
assert status.latest_release.version == "1.3.0"
|
|
assert len(status.releases_since_current) == 2
|
|
assert status.releases_since_current[0].version == "1.3.0"
|
|
assert status.releases_since_current[1].version == "1.2.0"
|
|
|
|
@pytest.mark.unit
|
|
def test_releases_since_current_empty_when_no_update(self):
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = [
|
|
{
|
|
"id": 1,
|
|
"tag_name": "v1.0.0",
|
|
"name": "v1.0.0",
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"html_url": "https://example.com/v1.0.0",
|
|
"body": "",
|
|
"assets": [{"id": 100, "name": "Jarvis-macOS-arm64.zip",
|
|
"browser_download_url": "https://example.com/dl1", "size": 1000}],
|
|
},
|
|
]
|
|
|
|
with patch("desktop_app.updater.get_version", return_value=("1.0.0", "stable")):
|
|
with patch("requests.get", return_value=mock_response):
|
|
with patch("sys.platform", "darwin"):
|
|
with patch("platform.machine", return_value="arm64"):
|
|
status = check_for_updates()
|
|
assert status.update_available is False
|
|
assert status.releases_since_current == []
|
|
|
|
|
|
class TestChangelogParsing:
|
|
"""Tests for release notes parsing."""
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_empty_notes_returns_empty_dict(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
assert parse_release_notes("") == {}
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_feat_commit_goes_to_new_features(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
result = parse_release_notes("* feat(memory): add tag optimisation (#327)")
|
|
assert "New Features" in result
|
|
assert len(result["New Features"]) == 1
|
|
assert result["New Features"][0].text == "add tag optimisation"
|
|
assert result["New Features"][0].pr_number == 327
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_fix_commit_goes_to_bug_fixes(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
result = parse_release_notes(
|
|
"* fix(listener): show city placeholder when GeoLite2 DB is missing (#331)"
|
|
)
|
|
assert "Bug Fixes" in result
|
|
assert "show city placeholder" in result["Bug Fixes"][0].text
|
|
assert result["Bug Fixes"][0].pr_number == 331
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_strips_by_attribution(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
result = parse_release_notes("* fix: some fix by @someuser")
|
|
assert "Bug Fixes" in result
|
|
assert "@someuser" not in result["Bug Fixes"][0].text
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_strips_full_changelog_footer(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
notes = (
|
|
"* feat: new feature (#1)\n\n"
|
|
"**Full Changelog**: https://github.com/owner/repo/compare/v1.0...v1.1"
|
|
)
|
|
result = parse_release_notes(notes)
|
|
total = sum(len(v) for v in result.values())
|
|
assert total == 1
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_unknown_prefix_goes_to_changes(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
result = parse_release_notes("* Some change without prefix")
|
|
assert "Changes" in result
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_categories_ordered_feat_before_fix_before_maintenance(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
notes = "* chore: update deps (#1)\n* feat: new thing (#2)\n* fix: bug fix (#3)"
|
|
result = parse_release_notes(notes)
|
|
keys = list(result.keys())
|
|
assert keys.index("New Features") < keys.index("Bug Fixes") < keys.index("Maintenance")
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_github_auto_generated_format(self):
|
|
"""GitHub auto-generated notes use 'by @user in https://.../pull/NNN' format."""
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
notes = (
|
|
"## What's Changed\n"
|
|
"* fix(something): description by @contributor "
|
|
"in https://github.com/owner/repo/pull/123\n\n"
|
|
"**Full Changelog**: https://github.com/owner/repo/compare/v1.0...v1.1"
|
|
)
|
|
result = parse_release_notes(notes)
|
|
assert "Bug Fixes" in result
|
|
entry = result["Bug Fixes"][0]
|
|
assert "@contributor" not in entry.text
|
|
assert "https://" not in entry.text
|
|
assert entry.pr_number == 123
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_dash_bullets(self):
|
|
from desktop_app.update_dialog import parse_release_notes
|
|
result = parse_release_notes("- feat: a feature\n- fix: a fix")
|
|
assert "New Features" in result
|
|
assert "Bug Fixes" in result
|
|
|
|
|
|
class TestReleaseInfo:
|
|
"""Tests for ReleaseInfo dataclass."""
|
|
|
|
@pytest.mark.unit
|
|
def test_release_info_fields(self):
|
|
release = ReleaseInfo(
|
|
asset_id=100002,
|
|
tag_name="v1.2.3",
|
|
version="1.2.3",
|
|
name="Version 1.2.3",
|
|
prerelease=False,
|
|
html_url="https://github.com/isair/jarvis/releases/tag/v1.2.3",
|
|
download_url="https://github.com/isair/jarvis/releases/download/v1.2.3/Jarvis.zip",
|
|
asset_name="Jarvis-macOS-arm64.zip",
|
|
asset_size=52428800,
|
|
release_notes="## Changes\n- Bug fixes",
|
|
)
|
|
assert release.tag_name == "v1.2.3"
|
|
assert release.version == "1.2.3"
|
|
assert release.prerelease is False
|
|
assert release.asset_size == 52428800
|
|
assert release.asset_id == 100002
|
|
|
|
|
|
class TestInstallUpdateWindows:
|
|
"""Tests for Windows update installation."""
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_script_waits_for_pid(self, tmp_path):
|
|
"""Verify the Windows batch script waits for the current process to exit."""
|
|
import os
|
|
import subprocess
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock, call
|
|
|
|
# Create a mock zip file with Jarvis.exe
|
|
zip_path = tmp_path / "update.zip"
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
zf.writestr("Jarvis.exe", b"mock executable content")
|
|
|
|
# Mock get_app_path to return a fake path
|
|
mock_app_path = tmp_path / "Jarvis.exe"
|
|
mock_app_path.write_bytes(b"old executable")
|
|
|
|
# Import here to avoid issues with platform checks
|
|
from desktop_app.updater import install_update_windows
|
|
|
|
# Capture the batch script content via the Popen call
|
|
batch_content_captured = []
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if args[0] == "cmd" and args[1] == "/c":
|
|
# Read the batch script content
|
|
batch_path = Path(args[2])
|
|
if batch_path.exists():
|
|
batch_content_captured.append(batch_path.read_text())
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
# Mock CREATE_NO_WINDOW for non-Windows platforms
|
|
if not hasattr(subprocess, 'CREATE_NO_WINDOW'):
|
|
with patch.object(subprocess, 'CREATE_NO_WINDOW', 0x08000000, create=True):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
result = install_update_windows(zip_path)
|
|
else:
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
result = install_update_windows(zip_path)
|
|
|
|
assert result is True
|
|
assert len(batch_content_captured) == 1
|
|
batch_content = batch_content_captured[0]
|
|
|
|
# Verify key elements of the PID-waiting batch script
|
|
current_pid = os.getpid()
|
|
assert f"pid eq {current_pid}" in batch_content
|
|
assert ":wait_loop" in batch_content
|
|
assert "goto wait_loop" in batch_content
|
|
assert "tasklist" in batch_content
|
|
assert "Process exited" in batch_content
|
|
|
|
# Verify the installer is run silently (not the old move/replace approach).
|
|
# We use /SILENT rather than /VERYSILENT so Inno Setup shows its own
|
|
# progress window during install — otherwise the user sees nothing
|
|
# between the download dialog closing and the new app launching.
|
|
assert "/SILENT" in batch_content
|
|
assert "/VERYSILENT" not in batch_content
|
|
assert "/SUPPRESSMSGBOXES" in batch_content
|
|
assert "move /y" not in batch_content
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_script_launches_updated_exe(self, tmp_path):
|
|
"""After silent install, the batch script must relaunch the upgraded exe.
|
|
|
|
Inno Setup's postinstall launch is skipped under /VERYSILENT, so the
|
|
updater itself has to start the new version — otherwise the user is
|
|
left with a stopped app after a successful update.
|
|
"""
|
|
import subprocess
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
zip_path = tmp_path / "update.zip"
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
zf.writestr("Jarvis.exe", b"mock executable content")
|
|
|
|
mock_app_path = tmp_path / "Program Files" / "Jarvis" / "Jarvis.exe"
|
|
mock_app_path.parent.mkdir(parents=True)
|
|
mock_app_path.write_bytes(b"old executable")
|
|
|
|
from desktop_app.updater import install_update_windows
|
|
|
|
batch_content_captured = []
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if args[0] == "cmd" and args[1] == "/c":
|
|
batch_path = Path(args[2])
|
|
if batch_path.exists():
|
|
batch_content_captured.append(batch_path.read_text())
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
if not hasattr(subprocess, 'CREATE_NO_WINDOW'):
|
|
with patch.object(subprocess, 'CREATE_NO_WINDOW', 0x08000000, create=True):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
install_update_windows(zip_path)
|
|
else:
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
install_update_windows(zip_path)
|
|
|
|
assert len(batch_content_captured) == 1
|
|
batch_content = batch_content_captured[0]
|
|
|
|
# The launch must come after the installer line so the new binary is
|
|
# in place when it runs.
|
|
installer_idx = batch_content.find("/SILENT")
|
|
launch_idx = batch_content.find(f'start "" "{mock_app_path}"')
|
|
assert installer_idx != -1, "installer line missing"
|
|
assert launch_idx != -1, "start line for upgraded exe missing"
|
|
assert launch_idx > installer_idx, "launch must follow install"
|
|
|
|
|
|
class TestInstallUpdateMacos:
|
|
"""Tests for macOS update installation."""
|
|
|
|
@pytest.mark.unit
|
|
def test_shell_script_waits_for_pid_and_relaunches(self, tmp_path):
|
|
"""macOS installer must wait for the current PID to exit, replace the
|
|
bundle with plain file ops (no Finder automation), and relaunch.
|
|
|
|
The previous AppleScript/Finder approach was failing mid-install on
|
|
some machines — it would trash the old app, prompt for file-editing
|
|
permission, then error out, leaving the user with no app. The shell
|
|
script approach matches Linux and avoids Finder entirely.
|
|
"""
|
|
import os
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
zip_path = tmp_path / "update.zip"
|
|
app_source = tmp_path / "zip_content" / "Jarvis.app"
|
|
app_source.mkdir(parents=True)
|
|
(app_source / "Contents").mkdir()
|
|
(app_source / "Contents" / "Info.plist").write_bytes(b"mock plist")
|
|
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
for f in app_source.rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, arcname=str(f.relative_to(tmp_path / "zip_content")))
|
|
|
|
mock_app_path = tmp_path / "Applications" / "Jarvis.app"
|
|
mock_app_path.mkdir(parents=True)
|
|
(mock_app_path / "existing").write_bytes(b"old bundle")
|
|
|
|
from desktop_app.updater import install_update_macos
|
|
|
|
script_content_captured = []
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if len(args) == 1 and args[0].endswith("update.sh"):
|
|
script_path = Path(args[0])
|
|
if script_path.exists():
|
|
script_content_captured.append(script_path.read_text())
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater._extract_macos_bundle", side_effect=_zipfile_extract_for_tests):
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
result = install_update_macos(zip_path)
|
|
|
|
assert result is True
|
|
assert len(script_content_captured) == 1
|
|
script_content = script_content_captured[0]
|
|
|
|
current_pid = os.getpid()
|
|
assert f"kill -0 {current_pid}" in script_content
|
|
assert "sleep 1" in script_content
|
|
# No Finder automation
|
|
assert "osascript" not in script_content
|
|
assert "Finder" not in script_content
|
|
# Bundle is replaced and relaunched
|
|
assert "mv " in script_content
|
|
assert "open " in script_content
|
|
|
|
# Previous bundle is preserved as a .backup for rollback, not deleted.
|
|
# This is important: if the new version fails to launch, the user can
|
|
# restore the backup manually.
|
|
backup_path = str(mock_app_path) + ".backup"
|
|
assert backup_path in script_content
|
|
assert f"mv '{mock_app_path}' '{backup_path}'" in script_content
|
|
# The old .backup from the previous update is cleared first.
|
|
assert f"rm -rf '{backup_path}'" in script_content
|
|
|
|
# Quarantine xattr is stripped so Gatekeeper doesn't re-prompt on every
|
|
# update for unsigned builds.
|
|
assert "xattr -dr com.apple.quarantine" in script_content
|
|
|
|
clear_backup_idx = script_content.find(f"rm -rf '{backup_path}'")
|
|
move_to_backup_idx = script_content.find(f"mv '{mock_app_path}' '{backup_path}'")
|
|
install_idx = script_content.find(f"mv '") # first mv is to backup, find install
|
|
xattr_idx = script_content.find("xattr -dr com.apple.quarantine")
|
|
open_idx = script_content.find("open ")
|
|
assert clear_backup_idx < move_to_backup_idx, "must clear old backup before creating new one"
|
|
assert move_to_backup_idx < xattr_idx, "backup happens before xattr strip"
|
|
assert xattr_idx < open_idx, "xattr strip must precede launch"
|
|
|
|
# LaunchServices caches the old bundle inode across the mv swap, so a
|
|
# bare `open` silently no-ops. Re-register the bundle and force a new
|
|
# instance, and fall back to execing the inner binary if `open` fails
|
|
# — otherwise the update "installs" but never relaunches.
|
|
from desktop_app.updater import UPDATER_LOG_NAME
|
|
from desktop_app.paths import get_log_dir
|
|
assert "lsregister" in script_content
|
|
assert "open -n" in script_content
|
|
binary_path = str(mock_app_path / "Contents" / "MacOS" / "Jarvis")
|
|
assert binary_path in script_content, "fallback must exec the bundle's inner binary"
|
|
lsregister_idx = script_content.find("lsregister")
|
|
assert xattr_idx < lsregister_idx < open_idx, "lsregister must run after xattr and before open"
|
|
|
|
# Script output must be captured to a log file — otherwise detached
|
|
# failures leave no trace and we can't diagnose future relaunch bugs.
|
|
expected_log_path = str(get_log_dir() / UPDATER_LOG_NAME)
|
|
assert expected_log_path in script_content
|
|
|
|
@pytest.mark.unit
|
|
def test_binary_name_read_from_bundle_info_plist(self, tmp_path):
|
|
"""The fallback exec must target the actual CFBundleExecutable, not a
|
|
hardcoded "Jarvis" — so a future bundle rename doesn't silently break
|
|
the fallback relaunch."""
|
|
import plistlib
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
custom_binary_name = "JarvisNext"
|
|
zip_path = tmp_path / "update.zip"
|
|
app_source = tmp_path / "zip_content" / "Jarvis.app"
|
|
(app_source / "Contents").mkdir(parents=True)
|
|
plist_bytes = plistlib.dumps({"CFBundleExecutable": custom_binary_name})
|
|
(app_source / "Contents" / "Info.plist").write_bytes(plist_bytes)
|
|
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
for f in app_source.rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, arcname=str(f.relative_to(tmp_path / "zip_content")))
|
|
|
|
mock_app_path = tmp_path / "Applications" / "Jarvis.app"
|
|
mock_app_path.mkdir(parents=True)
|
|
|
|
from desktop_app.updater import install_update_macos
|
|
|
|
script_content_captured = []
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if len(args) == 1 and args[0].endswith("update.sh"):
|
|
script_content_captured.append(Path(args[0]).read_text())
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater._extract_macos_bundle", side_effect=_zipfile_extract_for_tests):
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
assert install_update_macos(zip_path) is True
|
|
|
|
script_content = script_content_captured[0]
|
|
expected_binary = str(mock_app_path / "Contents" / "MacOS" / custom_binary_name)
|
|
assert expected_binary in script_content, (
|
|
"fallback exec must use CFBundleExecutable from the new bundle"
|
|
)
|
|
# Shell-quoted; a bare 'Jarvis' occurrence would end with a single
|
|
# quote, whereas 'JarvisNext' does not.
|
|
hardcoded_binary = f"{mock_app_path / 'Contents' / 'MacOS' / 'Jarvis'}'"
|
|
assert hardcoded_binary not in script_content, (
|
|
"must not fall back to hardcoded 'Jarvis' when the bundle reports a different name"
|
|
)
|
|
|
|
@pytest.mark.unit
|
|
def test_shell_script_fallback_execs_binary_when_open_fails(self, tmp_path):
|
|
"""When `open -n` fails (the real-world failure mode we're fixing),
|
|
the generated script must actually exec the bundle's inner binary.
|
|
Structural assertions that the text is present are not enough —
|
|
quoting bugs or `$?` semantics could break the runtime path.
|
|
|
|
This test executes the generated script in a sandbox where `open` is
|
|
stubbed to exit non-zero, and asserts the fallback binary runs.
|
|
"""
|
|
import plistlib
|
|
import re
|
|
import time
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
zip_path = tmp_path / "update.zip"
|
|
app_source = tmp_path / "zip_content" / "Jarvis.app"
|
|
(app_source / "Contents" / "MacOS").mkdir(parents=True)
|
|
(app_source / "Contents" / "Info.plist").write_bytes(
|
|
plistlib.dumps({"CFBundleExecutable": "Jarvis"})
|
|
)
|
|
# The fallback execs Contents/MacOS/<binary_name>; stub it with a
|
|
# shell script that writes a marker file we can check for.
|
|
marker_path = tmp_path / "fallback_fired.marker"
|
|
stub_binary = app_source / "Contents" / "MacOS" / "Jarvis"
|
|
stub_binary.write_text(f'#!/bin/bash\necho fired > {marker_path}\n')
|
|
stub_binary.chmod(0o755)
|
|
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
for f in app_source.rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, arcname=str(f.relative_to(tmp_path / "zip_content")))
|
|
|
|
mock_app_path = tmp_path / "Applications" / "Jarvis.app"
|
|
mock_app_path.mkdir(parents=True)
|
|
|
|
# PATH-shadowed stubs: `open` always fails, `xattr` no-ops. The real
|
|
# /System lsregister path won't exist in tests, so the script's
|
|
# `if [ -x "$LSREGISTER" ]` guard skips it cleanly.
|
|
stub_dir = tmp_path / "path_stubs"
|
|
stub_dir.mkdir()
|
|
(stub_dir / "open").write_text("#!/bin/bash\nexit 1\n")
|
|
(stub_dir / "open").chmod(0o755)
|
|
(stub_dir / "xattr").write_text("#!/bin/bash\nexit 0\n")
|
|
(stub_dir / "xattr").chmod(0o755)
|
|
|
|
from desktop_app.updater import install_update_macos
|
|
|
|
captured = {}
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if len(args) == 1 and args[0].endswith("update.sh"):
|
|
captured["script"] = Path(args[0])
|
|
captured["text"] = captured["script"].read_text()
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater._extract_macos_bundle", side_effect=_zipfile_extract_for_tests):
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
assert install_update_macos(zip_path) is True
|
|
|
|
# Python's zipfile.extractall doesn't restore the Unix exec bit, so
|
|
# the stub binary inside the extracted new bundle comes out without
|
|
# +x — the nohup fallback would then fail with EACCES, which would
|
|
# hide real exec failures behind a test-infrastructure bug. Walk the
|
|
# new bundle (located from the `mv <new>` line in the script) and
|
|
# restore the exec bit before running.
|
|
new_app_match = re.search(r"mv '([^']+\.app)' '" + re.escape(str(mock_app_path)) + "'",
|
|
captured["text"])
|
|
assert new_app_match, "could not find extracted new_app path in script"
|
|
new_binary = Path(new_app_match.group(1)) / "Contents" / "MacOS" / "Jarvis"
|
|
new_binary.chmod(0o755)
|
|
|
|
# Strip the PID-wait loop so the test doesn't hang on the parent PID,
|
|
# and swap the log redirect for stdout so any script errors surface in
|
|
# the pytest output rather than being hidden.
|
|
script_text = captured["text"]
|
|
script_text = re.sub(
|
|
r"while kill -0 \d+ 2>/dev/null; do\s*\n\s*sleep 1\s*\ndone",
|
|
":",
|
|
script_text,
|
|
)
|
|
script_text = re.sub(r'^exec >> .*$', 'true', script_text, count=1, flags=re.MULTILINE)
|
|
# Drop the log-rotation preamble — it references the same log file
|
|
# we've just neutered.
|
|
script_text = re.sub(
|
|
r'LOG_FILE=.*?\nif \[ -f "\$LOG_FILE".*?fi\n',
|
|
'',
|
|
script_text,
|
|
count=1,
|
|
flags=re.DOTALL,
|
|
)
|
|
# Fallback nohup also redirects to $LOG_FILE; neutralise it.
|
|
script_text = script_text.replace('>> "$LOG_FILE" 2>&1', '>/dev/null 2>&1')
|
|
runnable = tmp_path / "run.sh"
|
|
runnable.write_text(script_text)
|
|
runnable.chmod(0o755)
|
|
|
|
env = os.environ.copy()
|
|
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
|
|
result = subprocess.run(
|
|
["bash", str(runnable)],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"script failed: stdout={result.stdout!r} stderr={result.stderr!r}"
|
|
)
|
|
|
|
# The fallback is backgrounded via nohup, give it a moment to run.
|
|
for _ in range(20):
|
|
if marker_path.exists():
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
assert marker_path.exists(), (
|
|
"fallback binary did not execute when `open` failed — "
|
|
"the user would be left without a running app after update"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_uses_ditto_to_preserve_bundle_symlinks(self, tmp_path):
|
|
"""PyInstaller's Qt bundle contains symlinks (framework
|
|
Versions/Current, etc.) that Python's zipfile silently flattens into
|
|
regular files — the extracted bundle then fails to launch with
|
|
"Jarvis.app can't be opened". The updater must extract with
|
|
`/usr/bin/ditto` when it is available, not zipfile."""
|
|
import plistlib
|
|
import zipfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
zip_path = tmp_path / "update.zip"
|
|
app_source = tmp_path / "zip_content" / "Jarvis.app"
|
|
(app_source / "Contents").mkdir(parents=True)
|
|
(app_source / "Contents" / "Info.plist").write_bytes(
|
|
plistlib.dumps({"CFBundleExecutable": "Jarvis"})
|
|
)
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
for f in app_source.rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, arcname=str(f.relative_to(tmp_path / "zip_content")))
|
|
|
|
mock_app_path = tmp_path / "Applications" / "Jarvis.app"
|
|
mock_app_path.mkdir(parents=True)
|
|
|
|
# Stand in for /usr/bin/ditto with a real file that the updater's
|
|
# existence check will see; subprocess.run is mocked so we never
|
|
# actually execute it. The fake "runs" the command by extracting
|
|
# the zip so the rest of the installer sees the expected bundle.
|
|
fake_ditto = tmp_path / "fake_ditto"
|
|
fake_ditto.write_text("")
|
|
|
|
run_calls = []
|
|
|
|
def fake_run(args, **kwargs):
|
|
run_calls.append(args)
|
|
if isinstance(args, list) and len(args) >= 4 and args[0] == str(fake_ditto):
|
|
dest = Path(args[-1])
|
|
with zipfile.ZipFile(args[-2], "r") as zf:
|
|
zf.extractall(dest)
|
|
return MagicMock(returncode=0)
|
|
|
|
from desktop_app.updater import install_update_macos
|
|
|
|
with patch("desktop_app.updater.DITTO_PATH", str(fake_ditto)):
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
with patch("desktop_app.updater.subprocess.run", side_effect=fake_run):
|
|
with patch("desktop_app.updater.subprocess.Popen", return_value=MagicMock()):
|
|
assert install_update_macos(zip_path) is True
|
|
|
|
ditto_calls = [c for c in run_calls if isinstance(c, list) and c and c[0] == str(fake_ditto)]
|
|
assert ditto_calls, (
|
|
"updater must invoke ditto to extract the macOS bundle — "
|
|
"Python's zipfile drops symlinks and produces an unlaunchable bundle"
|
|
)
|
|
assert ditto_calls[0][1:3] == ["-x", "-k"], (
|
|
f"expected `ditto -x -k <src> <dest>`, got {ditto_calls[0]}"
|
|
)
|
|
|
|
@pytest.mark.unit
|
|
def test_falls_back_to_zipfile_when_ditto_missing(self, tmp_path):
|
|
"""When ditto is absent (non-macOS CI), extraction must fall back to
|
|
zipfile rather than raising FileNotFoundError. Non-macOS hosts never
|
|
hit this path in production, but the safety net keeps the unit suite
|
|
runnable off-macOS — regressing that would silently break CI."""
|
|
import zipfile
|
|
from desktop_app.updater import _extract_macos_bundle
|
|
|
|
zip_path = tmp_path / "bundle.zip"
|
|
payload_dir = tmp_path / "payload"
|
|
payload_dir.mkdir()
|
|
(payload_dir / "hello.txt").write_text("hi")
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
zf.write(payload_dir / "hello.txt", arcname="hello.txt")
|
|
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
missing_ditto = tmp_path / "does_not_exist"
|
|
assert not missing_ditto.exists()
|
|
|
|
with patch("desktop_app.updater.DITTO_PATH", str(missing_ditto)):
|
|
_extract_macos_bundle(zip_path, dest)
|
|
|
|
assert (dest / "hello.txt").read_text() == "hi", (
|
|
"fallback must still extract the zip when ditto is unavailable"
|
|
)
|
|
|
|
@pytest.mark.unit
|
|
def test_ditto_extraction_failure_surfaces_as_install_failure(self, tmp_path):
|
|
"""If ditto exits non-zero, install_update_macos must catch the
|
|
CalledProcessError and return False so the UI shows the generic
|
|
update-failed dialog — never crash the app or leave a half-applied
|
|
bundle behind."""
|
|
import zipfile
|
|
|
|
zip_path = tmp_path / "update.zip"
|
|
app_source = tmp_path / "zip_content" / "Jarvis.app" / "Contents"
|
|
app_source.mkdir(parents=True)
|
|
(app_source / "Info.plist").write_bytes(b"mock")
|
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
|
for f in (tmp_path / "zip_content").rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, arcname=str(f.relative_to(tmp_path / "zip_content")))
|
|
|
|
mock_app_path = tmp_path / "Applications" / "Jarvis.app"
|
|
mock_app_path.mkdir(parents=True)
|
|
|
|
fake_ditto = tmp_path / "fake_ditto"
|
|
fake_ditto.write_text("")
|
|
|
|
def fake_run(args, **kwargs):
|
|
raise subprocess.CalledProcessError(returncode=1, cmd=args)
|
|
|
|
from desktop_app.updater import install_update_macos
|
|
|
|
with patch("desktop_app.updater.DITTO_PATH", str(fake_ditto)):
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_path):
|
|
with patch("desktop_app.updater.subprocess.run", side_effect=fake_run):
|
|
with patch("desktop_app.updater.subprocess.Popen", return_value=MagicMock()) as popen:
|
|
result = install_update_macos(zip_path)
|
|
|
|
assert result is False, "ditto failure must surface as install-failed"
|
|
assert not popen.called, (
|
|
"must not launch the relaunch script after extraction failed"
|
|
)
|
|
|
|
|
|
class TestInstallUpdateLinux:
|
|
"""Tests for Linux update installation."""
|
|
|
|
@pytest.mark.unit
|
|
def test_shell_script_waits_for_pid(self, tmp_path):
|
|
"""Verify the Linux shell script waits for the current process to exit."""
|
|
import os
|
|
import tarfile
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
# Create a mock tar.gz file with Jarvis directory
|
|
tar_path = tmp_path / "update.tar.gz"
|
|
jarvis_dir = tmp_path / "jarvis_content" / "Jarvis"
|
|
jarvis_dir.mkdir(parents=True)
|
|
(jarvis_dir / "Jarvis").write_bytes(b"mock executable content")
|
|
|
|
with tarfile.open(tar_path, "w:gz") as tf:
|
|
tf.add(jarvis_dir, arcname="Jarvis")
|
|
|
|
# Mock get_app_path to return a fake path
|
|
mock_app_dir = tmp_path / "installed" / "Jarvis"
|
|
mock_app_dir.mkdir(parents=True)
|
|
(mock_app_dir / "Jarvis").write_bytes(b"old executable")
|
|
|
|
# Import here to avoid issues with platform checks
|
|
from desktop_app.updater import install_update_linux
|
|
|
|
# Capture the shell script content via the Popen call
|
|
script_content_captured = []
|
|
|
|
def capture_popen(args, **kwargs):
|
|
if len(args) == 1 and args[0].endswith("update.sh"):
|
|
# Read the shell script content
|
|
script_path = Path(args[0])
|
|
if script_path.exists():
|
|
script_content_captured.append(script_path.read_text())
|
|
return MagicMock()
|
|
|
|
with patch("desktop_app.updater.get_app_path", return_value=mock_app_dir):
|
|
with patch("desktop_app.updater.subprocess.Popen", side_effect=capture_popen):
|
|
result = install_update_linux(tar_path)
|
|
|
|
assert result is True
|
|
assert len(script_content_captured) == 1
|
|
script_content = script_content_captured[0]
|
|
|
|
# Verify key elements of the PID-waiting shell script
|
|
current_pid = os.getpid()
|
|
assert f"kill -0 {current_pid}" in script_content
|
|
assert "while" in script_content
|
|
assert "sleep 1" in script_content
|
|
assert "Process exited" in script_content
|
|
|
|
# Previous directory is kept as .backup for rollback.
|
|
backup_path = str(mock_app_dir) + ".backup"
|
|
assert backup_path in script_content
|
|
assert f"mv '{mock_app_dir}' '{backup_path}'" in script_content
|
|
|
|
|
|
class TestPathEscaping:
|
|
"""Tests for path escaping functions to prevent script injection."""
|
|
|
|
@pytest.mark.unit
|
|
def test_applescript_escapes_quotes(self):
|
|
path = Path('/Users/test/"quoted"/app')
|
|
escaped = _escape_applescript_path(path)
|
|
assert '\\"' in escaped
|
|
assert '"quoted"' not in escaped
|
|
|
|
@pytest.mark.unit
|
|
def test_applescript_escapes_backslashes(self):
|
|
path = Path('/Users/test\\backslash/app')
|
|
escaped = _escape_applescript_path(path)
|
|
assert '\\\\' in escaped
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Unix path test")
|
|
def test_applescript_normal_path_unchanged(self):
|
|
path = Path('/Applications/Jarvis.app')
|
|
escaped = _escape_applescript_path(path)
|
|
assert escaped == '/Applications/Jarvis.app'
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_rejects_percent_sign(self):
|
|
path = Path('C:\\Users\\test%USERPROFILE%\\app')
|
|
with pytest.raises(ValueError, match="unsafe character"):
|
|
_escape_batch_path(path)
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_rejects_ampersand(self):
|
|
path = Path('C:\\Users\\test&echo bad\\app')
|
|
with pytest.raises(ValueError, match="unsafe character"):
|
|
_escape_batch_path(path)
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_rejects_pipe(self):
|
|
path = Path('C:\\Users\\test|dir\\app')
|
|
with pytest.raises(ValueError, match="unsafe character"):
|
|
_escape_batch_path(path)
|
|
|
|
@pytest.mark.unit
|
|
def test_batch_normal_path_unchanged(self):
|
|
path = Path('C:\\Program Files\\Jarvis\\Jarvis.exe')
|
|
escaped = _escape_batch_path(path)
|
|
assert escaped == 'C:\\Program Files\\Jarvis\\Jarvis.exe'
|
|
|
|
@pytest.mark.unit
|
|
def test_shell_escapes_single_quotes(self):
|
|
path = Path("/Users/test's folder/app")
|
|
escaped = _escape_shell_path(path)
|
|
# Single quotes should be escaped by ending quote, adding escaped quote, starting new quote
|
|
assert "'" in escaped
|
|
assert escaped.startswith("'")
|
|
assert escaped.endswith("'")
|
|
|
|
@pytest.mark.unit
|
|
def test_shell_handles_special_chars(self):
|
|
path = Path('/Users/test $HOME `whoami`/app')
|
|
escaped = _escape_shell_path(path)
|
|
# In single quotes, $ and backticks are literal
|
|
assert escaped.startswith("'")
|
|
assert escaped.endswith("'")
|
|
# The content should be preserved (not interpreted)
|
|
assert '$HOME' in escaped
|
|
assert '`whoami`' in escaped
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Unix path test")
|
|
def test_shell_normal_path_wrapped(self):
|
|
path = Path('/opt/Jarvis/Jarvis')
|
|
escaped = _escape_shell_path(path)
|
|
assert escaped == "'/opt/Jarvis/Jarvis'"
|