diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs new file mode 100644 index 0000000000..c2fe04bd93 --- /dev/null +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs @@ -0,0 +1,210 @@ +using System; +using System.IO; + +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies Enter Play Mode settings recovery for DomainReloadDisableScope. + /// + public sealed class DomainReloadDisableScopeTests + { + private static readonly string MarkerFilePath = DomainReloadDisableScopeRecovery.MarkerFilePathForTests; + private static readonly string TempFilePath = DomainReloadDisableScopeRecovery.TempFilePathForTests; + + private bool _originalEnabled; + private EnterPlayModeOptions _originalOptions; + private bool _markerFileExisted; + private string _markerFileContent; + private bool _tempFileExisted; + private string _tempFileContent; + + [SetUp] + public void SetUp() + { + _originalEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _originalOptions = EditorSettings.enterPlayModeOptions; + _markerFileExisted = File.Exists(MarkerFilePath); + _markerFileContent = _markerFileExisted ? File.ReadAllText(MarkerFilePath) : string.Empty; + _tempFileExisted = File.Exists(TempFilePath); + _tempFileContent = _tempFileExisted ? File.ReadAllText(TempFilePath) : string.Empty; + + DomainReloadDisableScope.ResetActiveScopeCountForTests(); + DomainReloadDisableScopeRecovery.ClearPendingRestoreForTests(); + } + + [TearDown] + public void TearDown() + { + EditorSettings.enterPlayModeOptionsEnabled = _originalEnabled; + EditorSettings.enterPlayModeOptions = _originalOptions; + DomainReloadDisableScope.ResetActiveScopeCountForTests(); + DomainReloadDisableScopeRecovery.ClearPendingRestoreForTests(); + RestoreFile(MarkerFilePath, _markerFileExisted, _markerFileContent); + RestoreFile(TempFilePath, _tempFileExisted, _tempFileContent); + } + + [Test] + public void Dispose_RestoresOriginalSettingsAndDeletesMarkerFile() + { + // Verifies that a completed scope restores settings and removes its recovery marker. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + using (DomainReloadDisableScope scope = new DomainReloadDisableScope()) + { + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + } + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + } + + [Test] + public void Dispose_KeepsDomainReloadDisabled_UntilLastNestedScopeDisposes() + { + // Verifies that nested scopes restore settings only after the final scope exits. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope outerScope = new DomainReloadDisableScope(); + DomainReloadDisableScope innerScope = new DomainReloadDisableScope(); + + innerScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + + outerScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + } + + [Test] + public void RestoreIfPending_RestoresOriginalSettings_WhenScopeWasAbandoned() + { + // Verifies that a marker left by an abandoned scope can restore the original settings. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope scope = new DomainReloadDisableScope(); + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + + DomainReloadDisableScopeRecovery.RestoreIfPending(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + System.GC.KeepAlive(scope); + } + + [Test] + public void Constructor_RestoresStaleMarker_BeforeSavingNewRunMarker() + { + // Verifies that stale recovery state is consumed before the next run records its baseline. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope abandonedScope = new DomainReloadDisableScope(); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + DomainReloadDisableScope.RecoverAbandonedScopeBeforeNewRun(); + + DomainReloadDisableScope nextScope = new DomainReloadDisableScope(); + DomainReloadDisableScopeRecoveryData markerData = DomainReloadDisableScopeRecovery.ReadMarkerDataForTests(); + + Assert.That(markerData.originalOptionsEnabled, Is.False); + Assert.That(markerData.originalOptions, Is.EqualTo((int)EnterPlayModeOptions.None)); + + nextScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + System.GC.KeepAlive(abandonedScope); + } + + [Test] + public void RestoreIfPending_LeavesNoMarkerFile_AfterSuccessfulRestore() + { + // Verifies that successful recovery deletes the marker instead of saving a cleared state. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + DomainReloadDisableScope scope = new DomainReloadDisableScope(); + + DomainReloadDisableScopeRecovery.RestoreIfPending(); + + Assert.That(File.Exists(MarkerFilePath), Is.False); + Assert.That(File.Exists(TempFilePath), Is.False); + System.GC.KeepAlive(scope); + } + + [Test] + public void RestoreIfPending_RejectsUnsupportedEnterPlayModeOptionBits() + { + // Verifies that corrupted markers cannot write unsupported option bits into EditorSettings. + SetEnterPlayModeSettings(true, EnterPlayModeOptions.DisableDomainReload); + string json = "{ \"originalOptionsEnabled\": false, \"originalOptions\": 1024 }"; + File.WriteAllText(MarkerFilePath, json); + + InvalidOperationException exception = + Assert.Throws(() => DomainReloadDisableScopeRecovery.RestoreIfPending()); + + Assert.That(exception.Message, Does.Contain("supported Enter Play Mode option bits")); + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(File.Exists(MarkerFilePath), Is.False); + } + + [Test] + public void SaveCurrentSettings_ThrowsAndKeepsExistingMarker_WhenMarkerAlreadyExists() + { + // Verifies that saving refuses to replace an unrecovered marker. + string directoryPath = Path.GetDirectoryName(MarkerFilePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + string existingJson = "{ \"originalOptionsEnabled\": true, \"originalOptions\": 1 }"; + File.WriteAllText(MarkerFilePath, existingJson); + + LogAssert.Expect(LogType.Assert, "recovery marker must be restored before saving a new marker"); + InvalidOperationException exception = + Assert.Throws(() => DomainReloadDisableScopeRecovery.SaveCurrentSettings()); + + Assert.That(exception.Message, Does.Contain("must be restored before saving")); + Assert.That(File.ReadAllText(MarkerFilePath), Is.EqualTo(existingJson)); + Assert.That(File.Exists(TempFilePath), Is.False); + } + + private static void SetEnterPlayModeSettings(bool enabled, EnterPlayModeOptions options) + { + EditorSettings.enterPlayModeOptionsEnabled = enabled; + EditorSettings.enterPlayModeOptions = options; + } + + private static void RestoreFile(string filePath, bool fileExisted, string fileContent) + { + if (!fileExisted) + { + return; + } + + string directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, fileContent); + } + } +} diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta new file mode 100644 index 0000000000..2d9a8dbd09 --- /dev/null +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 252ce50bd4844006a19e039419a86783 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs new file mode 100644 index 0000000000..8710f4b962 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..d8c60f08a1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4e094b1aa114af78472170716ef9a4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs index 1528e70c7e..ead86df2e0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs @@ -8,13 +8,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class DomainReloadDisableScope : IDisposable { - private readonly bool _originalEnabled; - private readonly EnterPlayModeOptions _originalOptions; + private static int _activeScopeCount; + private bool _disposed; public DomainReloadDisableScope() { - _originalEnabled = EditorSettings.enterPlayModeOptionsEnabled; - _originalOptions = EditorSettings.enterPlayModeOptions; + if (_activeScopeCount == 0) + { + DomainReloadDisableScopeRecovery.RestoreIfPending(); + DomainReloadDisableScopeRecovery.SaveCurrentSettings(); + } + + _activeScopeCount++; EditorSettings.enterPlayModeOptionsEnabled = true; EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; @@ -22,8 +27,42 @@ public DomainReloadDisableScope() public void Dispose() { - EditorSettings.enterPlayModeOptionsEnabled = _originalEnabled; - EditorSettings.enterPlayModeOptions = _originalOptions; + if (_disposed) + { + return; + } + + _disposed = true; + System.Diagnostics.Debug.Assert(_activeScopeCount > 0, "active scope count must be positive before dispose"); + _activeScopeCount--; + + if (_activeScopeCount == 0) + { + DomainReloadDisableScopeRecovery.RestoreIfPending(); + } + } + + /// + /// Resets stale scope state before a new PlayMode test run starts. + /// + internal static void RecoverAbandonedScopeBeforeNewRun() + { + if (_activeScopeCount == 0) + { + return; + } + + if (!DomainReloadDisableScopeRecovery.HasPendingRestore()) + { + return; + } + + _activeScopeCount = 0; + } + + internal static void ResetActiveScopeCountForTests() + { + _activeScopeCount = 0; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs new file mode 100644 index 0000000000..e5aa325a1b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; + +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Restores Enter Play Mode settings when DomainReloadDisableScope was interrupted. + /// + internal static class DomainReloadDisableScopeRecovery + { + // Unity can persist this deprecated bit, but referencing the enum member raises CS0618. + private const int DisableSceneBackupUnlessDirtyOptionBit = 4; + private const int ValidEnterPlayModeOptionsMask = + (int)(EnterPlayModeOptions.DisableDomainReload + | EnterPlayModeOptions.DisableSceneReload) + | DisableSceneBackupUnlessDirtyOptionBit; + + internal static string MarkerFilePathForTests => DomainReloadDisableScopeRecoveryConstants.MarkerFilePath; + internal static string TempFilePathForTests => DomainReloadDisableScopeRecoveryConstants.TempFilePath; + + [InitializeOnLoadMethod] + private static void RestorePendingSettingsOnEditorLoad() + { + if (AssetDatabase.IsAssetImportWorkerProcess()) + { + return; + } + + RestoreIfPending(); + } + + /// + /// Saves the current Enter Play Mode settings before DomainReloadDisableScope changes them. + /// + internal static void SaveCurrentSettings() + { + bool markerExists = File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + Debug.Assert( + !markerExists, + "recovery marker must be restored before saving a new marker"); + if (markerExists) + { + throw new InvalidOperationException( + "Domain reload recovery marker must be restored before saving a new marker."); + } + + DomainReloadDisableScopeRecoveryData markerData = new DomainReloadDisableScopeRecoveryData + { + originalOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled, + originalOptions = (int)EditorSettings.enterPlayModeOptions + }; + + SaveMarkerData(markerData); + } + + /// + /// Restores Enter Play Mode settings saved before DomainReloadDisableScope changed them. + /// + internal static void RestoreIfPending() + { + DeleteTempFileIfExists(); + if (!File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) + { + return; + } + + DomainReloadDisableScopeRecoveryData markerData = ReadMarkerData(); + EditorSettings.enterPlayModeOptionsEnabled = markerData.originalOptionsEnabled; + EditorSettings.enterPlayModeOptions = (EnterPlayModeOptions)markerData.originalOptions; + DeleteMarkerFileIfExists(); + } + + /// + /// Deletes any saved Enter Play Mode settings pending restore. + /// + internal static void ClearPendingRestoreForTests() + { + DeleteTempFileIfExists(); + DeleteMarkerFileIfExists(); + } + + internal static bool HasPendingRestoreForTests() + { + return HasPendingRestore(); + } + + internal static bool HasPendingRestore() + { + return File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + } + + internal static DomainReloadDisableScopeRecoveryData ReadMarkerDataForTests() + { + return ReadMarkerData(); + } + + private static void SaveMarkerData(DomainReloadDisableScopeRecoveryData markerData) + { + Debug.Assert(markerData != null, "markerData must not be null"); + + string directoryPath = DomainReloadDisableScopeRecoveryConstants.DirectoryPath; + if (!Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + DeleteTempFileIfExists(); + string json = JsonUtility.ToJson(markerData, true); + File.WriteAllText(DomainReloadDisableScopeRecoveryConstants.TempFilePath, json); + File.Move( + DomainReloadDisableScopeRecoveryConstants.TempFilePath, + DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + + Debug.Assert( + File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath), + "marker file must exist after save"); + } + + private static DomainReloadDisableScopeRecoveryData ReadMarkerData() + { + string json = File.ReadAllText(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + DomainReloadDisableScopeRecoveryData markerData = + JsonUtility.FromJson(json); + if (markerData == null) + { + ThrowInvalidMarker("Domain reload recovery marker must contain valid JSON."); + } + + ValidateOriginalOptions(markerData.originalOptions); + return markerData; + } + + private static void ValidateOriginalOptions(int originalOptions) + { + if ((originalOptions & ~ValidEnterPlayModeOptionsMask) == 0) + { + return; + } + + ThrowInvalidMarker( + "Domain reload recovery marker must contain supported Enter Play Mode option bits."); + } + + private static void ThrowInvalidMarker(string message) + { + DeleteMarkerFileIfExists(); + throw new InvalidOperationException(message); + } + + private static void DeleteMarkerFileIfExists() + { + if (File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) + { + File.Delete(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + } + } + + private static void DeleteTempFileIfExists() + { + if (File.Exists(DomainReloadDisableScopeRecoveryConstants.TempFilePath)) + { + File.Delete(DomainReloadDisableScopeRecoveryConstants.TempFilePath); + } + } + } + + /// + /// Stores the original Enter Play Mode settings needed for interrupted scope recovery. + /// + [Serializable] + internal sealed class DomainReloadDisableScopeRecoveryData + { + public bool originalOptionsEnabled; + public int originalOptions; + } + + /// + /// Centralizes DomainReloadDisableScope recovery marker paths. + /// + internal static class DomainReloadDisableScopeRecoveryConstants + { + internal const string DirectoryPath = "UserSettings"; + internal const string MarkerFileName = "uloop-domain-reload-recovery.json"; + internal const string TempFileName = MarkerFileName + ".tmp"; + internal static readonly string MarkerFilePath = Path.Combine(DirectoryPath, MarkerFileName); + internal static readonly string TempFilePath = Path.Combine(DirectoryPath, TempFileName); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta new file mode 100644 index 0000000000..d6416a461b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a8678771b1d45eb84fe6ff1f5188f87 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs index 25ba913383..4f940a4a51 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs @@ -41,6 +41,7 @@ public static async Task ExecutePlayModeTest( CancellationToken ct) { ct.ThrowIfCancellationRequested(); + DomainReloadDisableScope.RecoverAbandonedScopeBeforeNewRun(); using DomainReloadDisableScope scope = new DomainReloadDisableScope(); return await ExecuteTestWithEventNotification(TestMode.PlayMode, filter, ct); }