diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fca9cbc..0423f7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,25 @@ jobs: Write-Warning "Defender scan did not complete cleanly (exit $LASTEXITCODE)" } + # Launches the packaged exe in --smoke mode: it must open the real + # settings child process and sustain IPC polls (checks for crashes and surch) + - name: smoke test exe + shell: pwsh + run: | + $process = Start-Process -FilePath "dist/CheevoPresence.exe" -ArgumentList "--smoke" -PassThru -Wait + if ($process.ExitCode -ne 0) { + Write-Error "Smoke test failed with exit code $($process.ExitCode)" + exit 1 + } + + - name: upload smoke log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: windows-smoke-cheevo.log + path: ~/AppData/Roaming/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore + - name: prepare artifact shell: pwsh run: | @@ -116,6 +135,18 @@ jobs: - name: build app run: ./build_macos.sh + - name: smoke test app + shell: bash + run: dist/CheevoPresence.app/Contents/MacOS/CheevoPresence --smoke + + - name: upload smoke log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: macos-${{ matrix.arch }}-smoke-cheevo.log + path: ~/Library/Application Support/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore + - name: prepare artifact shell: bash run: | @@ -159,11 +190,24 @@ jobs: gir1.2-gtk-3.0 \ gir1.2-webkit2-4.0 \ gir1.2-ayatanaappindicator3-0.1 \ - libayatana-appindicator3-1 + libayatana-appindicator3-1 \ + xvfb - name: build binary run: ./build_linux.sh + - name: smoke test binary + shell: bash + run: xvfb-run -a dist/CheevoPresence --smoke + + - name: upload smoke log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: linux-smoke-cheevo.log + path: ~/.local/state/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore + - name: prepare artifact shell: bash run: | diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..816e693 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,116 @@ +name: smoke + +# Source-mode smoke test: launches the real app with --smoke on every OS so +# bugs that only appear when the actual settings child process runs +on: + pull_request: + paths: + - "desktop/**" + - "launch_windows.py" + - "launch_macos.py" + - "launch_linux.py" + - "requirements/**" + - ".github/workflows/smoke.yml" + +permissions: + contents: read + +concurrency: + group: smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-22.04] + + steps: + - name: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: setup python + if: runner.os != 'Linux' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + requirements/base.txt + requirements/windows.txt + requirements/macos.txt + + - name: install dependencies (windows) + if: runner.os == 'Windows' + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements/windows.txt + + - name: install dependencies (macos) + if: runner.os == 'macOS' + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements/macos.txt + + # The Linux GTK/WebKit stack comes from apt, so the venv must see the + # system site-packages (same approach as build_linux.sh). + - name: install dependencies (linux) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + python3-venv \ + python3-pip \ + python3-tk \ + python3-gi \ + python3-gi-cairo \ + gir1.2-gtk-3.0 \ + gir1.2-webkit2-4.0 \ + gir1.2-ayatanaappindicator3-0.1 \ + libayatana-appindicator3-1 \ + xvfb + python3 -m venv --system-site-packages .smoke-venv + .smoke-venv/bin/python -m pip install --upgrade pip + .smoke-venv/bin/python -m pip install -r requirements/linux.txt + + - name: run smoke (windows) + if: runner.os == 'Windows' + run: python launch_windows.py --smoke + + - name: run smoke (macos) + if: runner.os == 'macOS' + run: python launch_macos.py --smoke + + - name: run smoke (linux) + if: runner.os == 'Linux' + shell: bash + run: xvfb-run -a .smoke-venv/bin/python launch_linux.py --smoke + + - name: upload smoke log (windows) + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: smoke-windows-cheevo.log + path: ~/AppData/Roaming/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore + + - name: upload smoke log (macos) + if: failure() && runner.os == 'macOS' + uses: actions/upload-artifact@v7 + with: + name: smoke-macos-cheevo.log + path: ~/Library/Application Support/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore + + - name: upload smoke log (linux) + if: failure() && runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: smoke-linux-cheevo.log + path: ~/.local/state/CheevoPresence/logs/cheevo.log + if-no-files-found: ignore diff --git a/desktop/core/constants.py b/desktop/core/constants.py index ef3b0b2..2922ba3 100644 --- a/desktop/core/constants.py +++ b/desktop/core/constants.py @@ -10,11 +10,10 @@ RELEASES_LATEST_API_URL = "https://api.github.com/repos/denzi-gh/CheevoPresence/releases/latest" UPDATE_TEST_FILE_NAME = "update-test.json" -# CLI flags shared across the launchers, platform entrypoints, and adapters. -# Centralised here so a flag string is defined once and platform shells can -# reference it without importing each other (which would be circular). +# CLI flags shared across the launchers, platform entrypoints, and adapters TRAY_FLAG = "--tray" EXIT_APP_FLAG = "--exit" +SMOKE_FLAG = "--smoke" MAC_SETTINGS_CLIENT_FLAG = "--mac-settings-client" WINDOWS_SETTINGS_CLIENT_FLAG = "--windows-settings-client" LINUX_SETTINGS_CLIENT_FLAG = "--linux-settings-client" diff --git a/desktop/platform/windows.py b/desktop/platform/windows.py index 1527e22..9c77e05 100644 --- a/desktop/platform/windows.py +++ b/desktop/platform/windows.py @@ -359,7 +359,7 @@ def set_autostart(enable): try: import winreg - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_REG_KEY, 0, winreg.KEY_SET_VALUE) + key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, STARTUP_REG_KEY, 0, winreg.KEY_SET_VALUE) try: if enable: exe = get_exe_path() diff --git a/desktop/shell/entrypoint.py b/desktop/shell/entrypoint.py index a31bbfb..3d3d17c 100644 --- a/desktop/shell/entrypoint.py +++ b/desktop/shell/entrypoint.py @@ -9,7 +9,7 @@ import logging import sys -from desktop.core.constants import EXIT_APP_FLAG, TRAY_FLAG +from desktop.core.constants import EXIT_APP_FLAG, SMOKE_FLAG, TRAY_FLAG from desktop.core.log_events import AREA_STARTUP, log_event from desktop.platform import get_platform_services from desktop.runtime.controller import AppController @@ -32,6 +32,11 @@ def run_shell(platform_name, run_app): log_startup_diagnostics(platform) log_event(logger, AREA_STARTUP, "entrypoint_started", platform=platform_name, mode=mode) + if SMOKE_FLAG in sys.argv: + from desktop.shell.smoke import run_smoke + + sys.exit(run_smoke(platform_name, platform)) + if platform.handle_special_args(sys.argv): log_event(logger, AREA_STARTUP, "platform_helper_handled", platform=platform_name) return diff --git a/desktop/shell/ipc.py b/desktop/shell/ipc.py index 4bf1ed0..dc70a54 100644 --- a/desktop/shell/ipc.py +++ b/desktop/shell/ipc.py @@ -113,9 +113,10 @@ def _format_ipc_error(exc): class SettingsHostService: - def __init__(self, controller, on_quit=None): + def __init__(self, controller, on_quit=None, on_request=None): self.controller = controller self.on_quit = on_quit + self.on_request = on_request self.address = "" self.auth_token = secrets.token_hex(32) self.listener = None @@ -236,6 +237,8 @@ def _serve(self): request = _read_message(conn) method = request.get("method") response = {"ok": True, "result": self._dispatch(request)} + if self.on_request: + self.on_request(method) if self._should_log_request(method, start): log_event( logger, diff --git a/desktop/shell/smoke.py b/desktop/shell/smoke.py new file mode 100644 index 0000000..ad0a00b --- /dev/null +++ b/desktop/shell/smoke.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import threading +import time + +from desktop.core.constants import ( + LINUX_SETTINGS_CLIENT_FLAG, + MAC_SETTINGS_CLIENT_FLAG, + TRAY_FLAG, + WINDOWS_SETTINGS_CLIENT_FLAG, +) +from desktop.core.log_events import AREA_STARTUP, log_event +from desktop.runtime.controller import AppController +from desktop.shell.ipc import RemoteAppController, SettingsHostService +from desktop.shell.web_settings import SETTINGS_UI_ENV + +logger = logging.getLogger(__name__) + +_CLIENT_FLAGS = { + "windows": WINDOWS_SETTINGS_CLIENT_FLAG, + "macos": MAC_SETTINGS_CLIENT_FLAG, + "linux": LINUX_SETTINGS_CLIENT_FLAG, +} + +MIN_ALIVE_SECONDS = 10.0 +MIN_GET_STATE_REQUESTS = 3 +MIN_POLL_SPAN_SECONDS = 3.0 +SECOND_INSTANCE_TIMEOUT_SECONDS = 15.0 +DEFAULT_DEADLINE_SECONDS = 60.0 + + +def _app_command(flag): + # Same command shape as the tray/menu-bar hosts use. + if getattr(sys, "frozen", False): + return [sys.executable, flag] + return [sys.executable, os.path.abspath(sys.argv[0]), flag] + + +def _verify_quit_roundtrip(service, quit_event): + # Drives the real quit path + try: + RemoteAppController(service.address, service.auth_token).quit_app() + except Exception: # noqa: BLE001 verdict boundary; the failure reason is in the log + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + exc_info=True, + reason="quit_request_failed", + ) + return False + if not quit_event.wait(timeout=5): + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + reason="quit_callback_missing", + ) + return False + return True + + +def _verify_second_instance_blocked(): + # While the smoke holds the single-instance lock, a second app process + # must back off and exit cleanly + probe = subprocess.Popen(_app_command(TRAY_FLAG)) + try: + exit_code = probe.wait(timeout=SECOND_INSTANCE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + probe.terminate() + try: + probe.wait(timeout=5) + except subprocess.TimeoutExpired: + probe.kill() + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + reason="second_instance_not_blocked", + ) + return False + if exit_code != 0: + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + reason="second_instance_crashed", + exit_code=exit_code, + ) + return False + return True + + +def run_smoke(platform_name, platform, deadline_seconds=None): + deadline = deadline_seconds or DEFAULT_DEADLINE_SECONDS + + def _watchdog_fired(): + log_event( + logger, + AREA_STARTUP, + "smoke_timeout", + level=logging.ERROR, + deadline_sec=deadline, + ) + os._exit(2) + + watchdog = threading.Timer(deadline, _watchdog_fired) + watchdog.daemon = True + watchdog.start() + + poll_lock = threading.Lock() + poll_times = [] + + def _on_request(method): + if method == "get_state": + with poll_lock: + poll_times.append(time.monotonic()) + + # Hold the lock ourselves so the second-instance probe has something to + # collide with + if not platform.acquire_single_instance(): + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + reason="single_instance_unavailable", + ) + watchdog.cancel() + return 1 + + # No worker start: the smoke test needs no credentials and no Discord. + controller = AppController(platform=platform) + quit_event = threading.Event() + service = SettingsHostService( + controller, + on_quit=quit_event.set, + on_request=_on_request, + ) + service.start() + child = None + try: + env = os.environ.copy() + env.update(service.get_launch_env()) + env[SETTINGS_UI_ENV] = "native" + child = subprocess.Popen(_app_command(_CLIENT_FLAGS[platform_name]), env=env) + log_event(logger, AREA_STARTUP, "smoke_child_started", pid=child.pid) + + started = time.monotonic() + while True: + if child.poll() is not None: + log_event( + logger, + AREA_STARTUP, + "smoke_failed", + level=logging.ERROR, + reason="child_exited", + exit_code=child.returncode, + ) + return 1 + with poll_lock: + polls = list(poll_times) + alive_sec = time.monotonic() - started + if ( + alive_sec >= MIN_ALIVE_SECONDS + and len(polls) >= MIN_GET_STATE_REQUESTS + and polls[-1] - polls[0] >= MIN_POLL_SPAN_SECONDS + ): + if not _verify_quit_roundtrip(service, quit_event): + return 1 + if not _verify_second_instance_blocked(): + return 1 + log_event( + logger, + AREA_STARTUP, + "smoke_passed", + polls=len(polls), + alive_sec=round(alive_sec, 1), + ) + return 0 + time.sleep(0.25) + finally: + watchdog.cancel() + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + service.stop() + try: + controller.shutdown(timeout=5) + except Exception: + logger.debug("smoke teardown failed", exc_info=True) diff --git a/tests/test_macos_autostart_native.py b/tests/test_macos_autostart_native.py new file mode 100644 index 0000000..bae010f --- /dev/null +++ b/tests/test_macos_autostart_native.py @@ -0,0 +1,46 @@ +import os +import plistlib +import sys +import tempfile +import unittest +from unittest.mock import patch + +try: + from desktop.platform import macos as macos_platform +except ImportError: + macos_platform = None + +from desktop.core.constants import TRAY_FLAG + + +@unittest.skipUnless(sys.platform == "darwin", "requires the macOS platform adapter") +@unittest.skipIf(macos_platform is None, "macos platform module unavailable") +class MacosAutostartNativeTests(unittest.TestCase): + """Writes a real LaunchAgent plist to a temp dir; launchctl stays stubbed + so CI never touches launchd.""" + + def test_launch_agent_plist_roundtrip(self): + with tempfile.TemporaryDirectory() as root: + plist_path = os.path.join(root, macos_platform.LAUNCH_AGENT_FILE) + with ( + patch.object(macos_platform, "get_launch_agent_path", return_value=plist_path), + patch.object(macos_platform, "_has_stable_install_path", return_value=True), + patch.object(macos_platform, "_launchctl_reload", return_value=None), + patch.object(macos_platform, "_run_launchctl") as launchctl, + ): + self.assertIsNone(macos_platform.set_autostart(True)) + self.assertTrue(os.path.exists(plist_path)) + with open(plist_path, "rb") as handle: + payload = plistlib.load(handle) + self.assertEqual(macos_platform.LAUNCH_AGENT_ID, payload["Label"]) + self.assertTrue(payload["RunAtLoad"]) + self.assertEqual(TRAY_FLAG, payload["ProgramArguments"][-1]) + + self.assertIsNone(macos_platform.set_autostart(False)) + self.assertFalse(os.path.exists(plist_path)) + # Disable calls launchctl bootout, but only through the stub. + self.assertTrue(launchctl.called) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_windows_autostart_native.py b/tests/test_windows_autostart_native.py new file mode 100644 index 0000000..3916d00 --- /dev/null +++ b/tests/test_windows_autostart_native.py @@ -0,0 +1,64 @@ +import os +import unittest +from unittest.mock import patch + +try: + from desktop.platform import windows as windows_platform +except ImportError: # tkinter can be missing on headless CI images + windows_platform = None + +TEST_REG_NAME = "CheevoPresenceCITest" + + +@unittest.skipUnless(os.name == "nt", "requires the real Windows registry") +@unittest.skipIf(windows_platform is None, "windows platform module unavailable") +class WindowsAutostartNativeTests(unittest.TestCase): + """Real HKCU roundtrip under a test value name, so CI exercises winreg.""" + + def _delete_test_value(self): + import winreg + + try: + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + windows_platform.STARTUP_REG_KEY, + 0, + winreg.KEY_SET_VALUE, + ) + except OSError: + return + try: + winreg.DeleteValue(key, TEST_REG_NAME) + except FileNotFoundError: + pass + finally: + winreg.CloseKey(key) + + def test_registry_roundtrip(self): + import winreg + + with patch.object(windows_platform, "STARTUP_REG_NAME", TEST_REG_NAME): + try: + self.assertIsNone(windows_platform.set_autostart(True)) + self.assertTrue(windows_platform.is_autostart_enabled()) + + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + windows_platform.STARTUP_REG_KEY, + 0, + winreg.KEY_READ, + ) + try: + value, _kind = winreg.QueryValueEx(key, TEST_REG_NAME) + finally: + winreg.CloseKey(key) + self.assertIn("--tray", value) + + self.assertIsNone(windows_platform.set_autostart(False)) + self.assertFalse(windows_platform.is_autostart_enabled()) + finally: + self._delete_test_value() + + +if __name__ == "__main__": + unittest.main()