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);
}