Files
javis_bot/tests/test_updater.py
javis-bot c4abf63f38
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
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
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.
2026-06-09 14:51:05 +09:00

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'"