diff --git a/docs/user_guide/setup-submitter.md b/docs/user_guide/setup-submitter.md index 20f69424..7d4c32fd 100644 --- a/docs/user_guide/setup-submitter.md +++ b/docs/user_guide/setup-submitter.md @@ -203,4 +203,23 @@ This example will use the Meerkat Demo from the Unreal Marketplace: 1. Note: Unreal Engine version autodetection is coming in a future release 1. Ready to Go! Hit "Render (Remote)". -1. You can go to Deadline Cloud Monitor and watch the progress of your job. \ No newline at end of file +1. You can go to Deadline Cloud Monitor and watch the progress of your job. + + +# Update Notifications + +The submitter plugin automatically checks for newer releases on GitHub when Unreal Editor starts. If an update is available, a dialog will prompt you to visit the release page. + +To deactivate update notifications, uncheck "Show submitter update notifications" under "General Settings" in the Deadline Cloud settings panel (Edit > Project Settings > Plugins > Deadline Cloud). + +Alternatively, you can use the CLI: + +``` +deadline config set settings.submitter_update_notification false +``` + +To re-enable: + +``` +deadline config set settings.submitter_update_notification true +``` diff --git a/src/unreal_plugin/Content/Python/init_unreal.py b/src/unreal_plugin/Content/Python/init_unreal.py index d4037bb2..90157555 100644 --- a/src/unreal_plugin/Content/Python/init_unreal.py +++ b/src/unreal_plugin/Content/Python/init_unreal.py @@ -162,6 +162,10 @@ def __init__( logger.info("INIT DEADLINE CLOUD") + from update_check import safe_check_and_show_update_dialog + + safe_check_and_show_update_dialog() + logger.info(f'DEADLINE CLOUD PATH: {os.getenv("DEADLINE_CLOUD")}') # These unused imports are REQUIRED!!! diff --git a/src/unreal_plugin/Content/Python/settings.py b/src/unreal_plugin/Content/Python/settings.py index 9d37f751..bf4db224 100644 --- a/src/unreal_plugin/Content/Python/settings.py +++ b/src/unreal_plugin/Content/Python/settings.py @@ -337,6 +337,13 @@ def save_to_aws_config( config=config_parser, ) + # general.show_update_notifications (settings.submitter_update_notification) + config.set_setting( + "settings.submitter_update_notification", + "true" if settings.general.show_update_notifications else "false", + config=config_parser, + ) + config_file.write_config(config_parser) if farm_queue_update: diff --git a/src/unreal_plugin/Content/Python/update_check.py b/src/unreal_plugin/Content/Python/update_check.py new file mode 100644 index 00000000..b2cb5fbf --- /dev/null +++ b/src/unreal_plugin/Content/Python/update_check.py @@ -0,0 +1,167 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Update checker for the Deadline Cloud for Unreal Engine plugin. + +Checks the GitHub releases API for a newer version and shows an Unreal +Editor dialog when an update is available. Respects the Deadline Cloud +``settings.submitter_update_notification`` config toggle. +""" + +from __future__ import annotations + +import json +import logging +import os +import socket +import ssl +import urllib.request +import urllib.error +import webbrowser + +import botocore +import unreal + +from packaging.version import Version, InvalidVersion + +from deadline.client.config import config_file +from deadline.unreal_submitter._version import version as _current_version + +logger = logging.getLogger(__name__) + +GITHUB_LATEST_RELEASE_URL = ( + "https://api.github.com/repos/aws-deadline/deadline-cloud-for-unreal-engine/releases/latest" +) +RELEASES_PAGE_URL = "https://github.com/aws-deadline/deadline-cloud-for-unreal-engine/releases" +SETUP_GUIDE_URL = "https://aws-deadline.github.io/unreal-engine/setup-submitter/" +_REQUEST_TIMEOUT_SECONDS = 5 + + +def _is_update_notification_enabled() -> bool: + """Check whether the user has opted in to update notifications.""" + return config_file.str2bool(config_file.get_setting("settings.submitter_update_notification")) + + +def _get_current_version() -> str: + """Return the currently installed plugin version string.""" + return _current_version + + +def _fetch_latest_version() -> str | None: + """Fetch the latest release tag from GitHub. + + Returns: + The version string (e.g. ``"0.6.5"``) or ``None`` on failure. + """ + # Pin to GitHub REST API v3 JSON format so the response shape stays stable. + req = urllib.request.Request( + GITHUB_LATEST_RELEASE_URL, + headers={"Accept": "application/vnd.github.v3+json"}, + ) + + # Build a strict TLS context: enforce TLS 1.2+ and use the botocore CA + # bundle so certificate verification works even in embedded Python + # environments (e.g. Unreal) that may lack system root certificates. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.load_verify_locations(_get_botocore_ca_bundle()) + + try: + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_SECONDS, context=ctx) as resp: + data = json.loads(resp.read().decode("utf-8")) + tag = data.get("tag_name", "") + return tag.lstrip("v") if tag else None + except urllib.error.URLError: + return None + except (socket.timeout, TimeoutError): + return None + except json.JSONDecodeError: + return None + + +def _get_botocore_ca_bundle() -> str: + """Return the path to botocore's bundled CA certificate bundle.""" + return os.path.join(os.path.dirname(botocore.__file__), "cacert.pem") + + +def _is_update_available(current: str, latest: str) -> bool: + """Return True if *latest* is strictly newer than *current*.""" + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def safe_check_and_show_update_dialog() -> bool: + """Check GitHub for a newer release and show an Unreal dialog if found. + + Returns: + ``True`` if the user chose to open the download page (caller may + want to skip opening the submitter), ``False`` otherwise. + """ + try: + return _check_and_show_update_dialog() + except Exception: + logger.debug("Update check failed -- skipping", exc_info=True) + return False + + +def _check_and_show_update_dialog() -> bool: + """Internal implementation of the update check and dialog flow.""" + if not _is_update_notification_enabled(): + return False + + current_version = _get_current_version() + + latest_version = _fetch_latest_version() + + if not latest_version: + return False + + if not _is_update_available(current_version, latest_version): + return False + + message = ( + f"Version {latest_version} of Deadline Cloud for Unreal Engine " + f"submitter is now available.\n\n" + f"Current: {current_version} -> New: {latest_version}\n\n" + f"View release notes:\n{RELEASES_PAGE_URL}\n\n" + "To disable these notifications, go to Edit > Project Settings > " + 'search for "Deadline" and uncheck "Show Submitter Update ' + 'Notifications" under General Settings.\n\n' + "Click 'Yes' to open the release page, or 'No' to dismiss." + ) + + response = unreal.EditorDialog.show_message( + "New version available", + message, + unreal.AppMsgType.YES_NO, + ) + + if response == unreal.AppReturnType.YES: + try: + webbrowser.open(RELEASES_PAGE_URL) + except Exception: + return False + + guide_message = ( + "Please follow the setup guide to install the new release and " + "then restart Unreal Engine to use the new version.\n\n" + f"Setup guide:\n{SETUP_GUIDE_URL}\n\n" + "Click 'Yes' to open the setup guide, or 'No' to dismiss." + ) + + guide_response = unreal.EditorDialog.show_message( + "Installation Guide", + guide_message, + unreal.AppMsgType.YES_NO, + ) + + if guide_response == unreal.AppReturnType.YES: + try: + webbrowser.open(SETUP_GUIDE_URL) + except Exception: + logger.debug("Failed to open setup guide URL", exc_info=True) + return True + + return False diff --git a/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/DeadlineCloudJobSettings/DeadlineCloudDeveloperSettings.cpp b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/DeadlineCloudJobSettings/DeadlineCloudDeveloperSettings.cpp index dd31bda6..054e10d6 100644 --- a/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/DeadlineCloudJobSettings/DeadlineCloudDeveloperSettings.cpp +++ b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/DeadlineCloudJobSettings/DeadlineCloudDeveloperSettings.cpp @@ -17,6 +17,7 @@ namespace DeadlineSettingsKeys const FString AutoAccept = TEXT("settings.auto_accept"); const FString ConflictResolution = TEXT("settings.conflict_resolution"); const FString LogLevel = TEXT("settings.log_level"); + const FString SubmitterUpdateNotification = TEXT("settings.submitter_update_notification"); } UDeadlineCloudDeveloperSettings::UDeadlineCloudDeveloperSettings() @@ -329,6 +330,9 @@ void UDeadlineCloudDeveloperSettings::RefreshFromDefaultProfileInternal() FString CurrentLoggingLevel = Library->GetAWSStringConfigSetting(DeadlineSettingsKeys::LogLevel); WorkStationConfiguration.General.CurrentLoggingLevel = CurrentLoggingLevel; + + FString SubmitterUpdateNotification = Library->GetAWSStringConfigSetting(DeadlineSettingsKeys::SubmitterUpdateNotification); + WorkStationConfiguration.General.ShowUpdateNotifications = SubmitterUpdateNotification != TEXT("false"); } } diff --git a/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/Tests/DeadlinePluginTest_UpdateDialog.spec.cpp b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/Tests/DeadlinePluginTest_UpdateDialog.spec.cpp new file mode 100644 index 00000000..546ba2dd --- /dev/null +++ b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Private/Tests/DeadlinePluginTest_UpdateDialog.spec.cpp @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +#pragma once +#include "Misc/AutomationTest.h" +#include "CoreMinimal.h" +#include "Engine/Engine.h" +#include "UObject/UObjectGlobals.h" +#include "DeadlineCloudJobSettings/DeadlineCloudDeveloperSettings.h" +#include "PythonAPILibraries/DeadlineCloudSettingsLibrary.h" + +// --------------------------------------------------------------------------- +// Spec tests for the update dialog flow. +// +// The update-notification dialog itself is driven by Python +// (update_check.py -> unreal.EditorDialog.show_message) and is covered by +// the Python unit tests in test_update_check.py. +// +// These C++ automation tests verify the settings-layer contract that the +// Python code depends on: +// - The ShowUpdateNotifications property defaults to true. +// - The config key "settings.submitter_update_notification" is readable +// and returns a valid value via the settings library. +// - Toggling the setting via the UI persists through SaveToFile and +// is correctly restored by RefreshFromDefaultProfileInternal. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// 1. Default value: a fresh struct should have notifications enabled +// --------------------------------------------------------------------------- +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FUpdateDialog_SettingDefaultIsTrue, + "DeadlineCloud.UpdateDialog.Setting.DefaultIsTrue", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FUpdateDialog_SettingDefaultIsTrue::RunTest(const FString& Parameters) +{ + FDeadlineCloudGeneralPluginSettings DefaultGeneral; + TestTrue( + TEXT("ShowUpdateNotifications should default to true on a fresh struct"), + DefaultGeneral.ShowUpdateNotifications); + + return true; +} + +// --------------------------------------------------------------------------- +// 2. Save-and-reload round-trip through the config file +// Toggles the setting off, saves to the Deadline Cloud config file via +// SaveToFile(), reloads via RefreshFromDefaultProfileInternal(), and +// verifies the bool was correctly persisted as the string "false" and +// mapped back to false. Then does the same for true. Restores original. +// --------------------------------------------------------------------------- +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FUpdateDialog_SettingSaveAndReloadRoundTrip, + "DeadlineCloud.UpdateDialog.Setting.SaveAndReloadRoundTrip", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FUpdateDialog_SettingSaveAndReloadRoundTrip::RunTest(const FString& Parameters) +{ + UDeadlineCloudDeveloperSettings* Settings = UDeadlineCloudDeveloperSettings::GetMutable(); + TestNotNull(TEXT("DeveloperSettings singleton must exist"), Settings); + if (!Settings) return false; + + UDeadlineCloudSettingsLibrary* Library = UDeadlineCloudSettingsLibrary::Get(); + if (!Library) + { + AddWarning(TEXT("DeadlineCloudSettingsLibrary not available (Python not initialized). Skipping round-trip test.")); + return true; + } + + const bool bOriginal = Settings->WorkStationConfiguration.General.ShowUpdateNotifications; + const FString ConfigKey = TEXT("settings.submitter_update_notification"); + + // --- Toggle OFF, save, reload, verify --- + Settings->WorkStationConfiguration.General.ShowUpdateNotifications = false; + Settings->SaveToFile(); + + FString SavedValue = Library->GetAWSStringConfigSetting(ConfigKey); + TestTrue( + TEXT("Config should contain 'false' after saving with notifications off"), + SavedValue.Equals(TEXT("false"), ESearchCase::IgnoreCase)); + + Settings->RefreshFromDefaultProfileInternal(); + TestFalse( + TEXT("ShowUpdateNotifications should be false after reload"), + Settings->WorkStationConfiguration.General.ShowUpdateNotifications); + + // --- Toggle ON, save, reload, verify --- + Settings->WorkStationConfiguration.General.ShowUpdateNotifications = true; + Settings->SaveToFile(); + + SavedValue = Library->GetAWSStringConfigSetting(ConfigKey); + TestTrue( + TEXT("Config should contain 'true' after saving with notifications on"), + SavedValue.Equals(TEXT("true"), ESearchCase::IgnoreCase)); + + Settings->RefreshFromDefaultProfileInternal(); + TestTrue( + TEXT("ShowUpdateNotifications should be true after reload"), + Settings->WorkStationConfiguration.General.ShowUpdateNotifications); + + // --- Restore original --- + Settings->WorkStationConfiguration.General.ShowUpdateNotifications = bOriginal; + Settings->SaveToFile(); + + return true; +} diff --git a/src/unreal_plugin/Source/UnrealDeadlineCloudService/Public/PythonAPILibraries/DeadlineCloudSettingsLibrary.h b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Public/PythonAPILibraries/DeadlineCloudSettingsLibrary.h index cf329aa7..b2a8c7f5 100644 --- a/src/unreal_plugin/Source/UnrealDeadlineCloudService/Public/PythonAPILibraries/DeadlineCloudSettingsLibrary.h +++ b/src/unreal_plugin/Source/UnrealDeadlineCloudService/Public/PythonAPILibraries/DeadlineCloudSettingsLibrary.h @@ -129,6 +129,12 @@ struct UNREALDEADLINECLOUDSERVICE_API FDeadlineCloudGeneralPluginSettings UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(GetOptions="GetLoggingLevels", DisplayPriority=8, Category="General Settings")) FString CurrentLoggingLevel; + /** + * Whether to show update notifications when a newer version of the plugin is available on GitHub. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(DisplayName="Show submitter update notifications", DisplayPriority=9, Category="General Settings")) + bool ShowUpdateNotifications = true; + }; /** diff --git a/test/deadline_submitter_for_unreal/unit/test_update_check.py b/test/deadline_submitter_for_unreal/unit/test_update_check.py new file mode 100644 index 00000000..65163ac4 --- /dev/null +++ b/test/deadline_submitter_for_unreal/unit/test_update_check.py @@ -0,0 +1,223 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +from __future__ import annotations + +import json +import sys +import urllib.error +from unittest.mock import patch, MagicMock + +import pytest + +# Mock the 'unreal' module before importing update_check, since it's only +# available inside the Unreal Engine Python environment. +_mock_unreal = MagicMock() +sys.modules["unreal"] = _mock_unreal + + +# Now safe to import — unreal is already in sys.modules. +from update_check import ( # noqa: E402 + _fetch_latest_version, + _is_update_available, + safe_check_and_show_update_dialog, + RELEASES_PAGE_URL, + SETUP_GUIDE_URL, +) + + +class TestFetchLatestVersion: + """Tests for _fetch_latest_version().""" + + @patch("update_check.ssl.create_default_context") + @patch("update_check.urllib.request.urlopen") + @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") + def test_returns_version_from_github(self, mock_ca, mock_urlopen, mock_ssl): + response_data = json.dumps({"tag_name": "v0.6.5"}).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_data + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + assert _fetch_latest_version() == "0.6.5" + + @patch("update_check.ssl.create_default_context") + @patch("update_check.urllib.request.urlopen") + @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") + def test_returns_none_on_network_error(self, mock_ca, mock_urlopen, mock_ssl): + mock_urlopen.side_effect = urllib.error.URLError("connection refused") + + assert _fetch_latest_version() is None + + @patch("update_check.ssl.create_default_context") + @patch("update_check.urllib.request.urlopen") + @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") + def test_returns_none_on_timeout(self, mock_ca, mock_urlopen, mock_ssl): + import socket + + mock_urlopen.side_effect = socket.timeout("timed out") + + assert _fetch_latest_version() is None + + @patch("update_check.ssl.create_default_context") + @patch("update_check.urllib.request.urlopen") + @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") + def test_returns_none_on_invalid_json(self, mock_ca, mock_urlopen, mock_ssl): + mock_resp = MagicMock() + mock_resp.read.return_value = b"not json" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + assert _fetch_latest_version() is None + + @patch("update_check.ssl.create_default_context") + @patch("update_check.urllib.request.urlopen") + @patch("update_check._get_botocore_ca_bundle", return_value="/fake/cacert.pem") + def test_returns_none_on_empty_tag(self, mock_ca, mock_urlopen, mock_ssl): + response_data = json.dumps({"tag_name": ""}).encode("utf-8") + mock_resp = MagicMock() + mock_resp.read.return_value = response_data + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + assert _fetch_latest_version() is None + + +class TestIsUpdateAvailable: + """Tests for _is_update_available().""" + + @pytest.mark.parametrize( + "current, latest, expected", + [ + # Newer version available + ("0.5.0", "0.6.5", True), + ("0.6.5", "0.10.0", True), + # Same version + ("0.6.5", "0.6.5", False), + # Current is newer (no update) + ("1.0.0", "0.6.5", False), + ("0.10.0", "0.6.5", False), + # Invalid versions + ("not-a-version", "0.6.5", False), + ("0.6.5", "not-a-version", False), + # Dev/post builds + ("0.6.5.post144", "0.7.0", True), + ("0.7.0", "0.6.5.post144", False), + ("0.6.5.post144", "0.6.5", False), + ], + ids=[ + "newer_available", + "newer_available_double_digit_minor", + "same_version", + "current_is_newer", + "current_is_newer_double_digit_minor", + "invalid_current", + "invalid_latest", + "dev_build_older_than_release", + "release_newer_than_dev_build", + "post_release_same_base", + ], + ) + def test_is_update_available(self, current, latest, expected): + assert _is_update_available(current, latest) is expected + + +class TestCheckAndShowUpdateDialog: + """Tests for safe_check_and_show_update_dialog().""" + + @patch("update_check._is_update_notification_enabled", return_value=False) + def test_returns_false_when_notifications_disabled(self, mock_enabled): + assert safe_check_and_show_update_dialog() is False + + @patch("update_check._fetch_latest_version", return_value=None) + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_returns_false_when_fetch_fails(self, mock_enabled, mock_current, mock_fetch): + assert safe_check_and_show_update_dialog() is False + + @patch("update_check._is_update_available", return_value=False) + @patch("update_check._fetch_latest_version", return_value="0.5.0") + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_returns_false_when_already_up_to_date( + self, mock_enabled, mock_current, mock_fetch, mock_available + ): + assert safe_check_and_show_update_dialog() is False + + @patch("update_check.webbrowser.open") + @patch("update_check._is_update_available", return_value=True) + @patch("update_check._fetch_latest_version", return_value="0.6.5") + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_returns_true_when_user_clicks_yes( + self, mock_enabled, mock_current, mock_fetch, mock_available, mock_webbrowser + ): + _mock_unreal.AppReturnType.YES = "YES" + _mock_unreal.EditorDialog.show_message.return_value = "YES" + + result = safe_check_and_show_update_dialog() + + assert result is True + # Should open releases page first, then setup guide + assert mock_webbrowser.call_count == 2 + mock_webbrowser.assert_any_call(RELEASES_PAGE_URL) + mock_webbrowser.assert_any_call(SETUP_GUIDE_URL) + # Should show two dialogs: the update dialog and the setup guide + assert _mock_unreal.EditorDialog.show_message.call_count == 2 + + # First dialog should mention release notes + first_call_args = _mock_unreal.EditorDialog.show_message.call_args_list[0] + assert RELEASES_PAGE_URL in first_call_args[0][1] + + # Second dialog should mention the setup guide + second_call_args = _mock_unreal.EditorDialog.show_message.call_args_list[1] + assert SETUP_GUIDE_URL in second_call_args[0][1] + + @patch("update_check._is_update_available", return_value=True) + @patch("update_check._fetch_latest_version", return_value="0.6.5") + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_returns_false_when_user_clicks_no( + self, mock_enabled, mock_current, mock_fetch, mock_available + ): + _mock_unreal.AppReturnType.YES = "YES" + _mock_unreal.EditorDialog.show_message.return_value = "NO" + + result = safe_check_and_show_update_dialog() + + assert result is False + + @patch("update_check.webbrowser.open") + @patch("update_check._is_update_available", return_value=True) + @patch("update_check._fetch_latest_version", return_value="0.6.5") + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_skips_setup_guide_when_user_dismisses_second_dialog( + self, mock_enabled, mock_current, mock_fetch, mock_available, mock_webbrowser + ): + _mock_unreal.AppReturnType.YES = "YES" + # First dialog: Yes, second dialog: No + _mock_unreal.EditorDialog.show_message.side_effect = ["YES", "NO"] + + result = safe_check_and_show_update_dialog() + + assert result is True + # Only the releases page should be opened, not the setup guide + mock_webbrowser.assert_called_once_with(RELEASES_PAGE_URL) + + @patch("update_check.webbrowser.open", side_effect=Exception("browser error")) + @patch("update_check._is_update_available", return_value=True) + @patch("update_check._fetch_latest_version", return_value="0.6.5") + @patch("update_check._get_current_version", return_value="0.5.0") + @patch("update_check._is_update_notification_enabled", return_value=True) + def test_returns_false_when_webbrowser_fails( + self, mock_enabled, mock_current, mock_fetch, mock_available, mock_webbrowser + ): + _mock_unreal.AppReturnType.YES = "YES" + _mock_unreal.EditorDialog.show_message.return_value = "YES" + + result = safe_check_and_show_update_dialog() + + assert result is False