"""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/; 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 ` 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 `, 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'"