diff --git a/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs b/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs new file mode 100644 index 000000000..ccd2c473d --- /dev/null +++ b/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs @@ -0,0 +1,30 @@ +using System.Reflection; + +using NUnit.Framework; +using UnityEditor.TestTools.TestRunner.Api; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// EditMode coverage that the cancel-method lookup resolves against the real TestRunnerApi type. + /// + public sealed class TestRunnerApiCancelMethodLookupEditModeTests + { + [Test] + public void Resolve_AgainstRealTestRunnerApi_ShouldFindCancelTestRunAndParameterlessIsRunActive() + { + // Verifies TF 1.3.9's real TestRunnerApi exposes CancelTestRun(string) and IsRunActive() + // to Public|NonPublic lookup so Option A wiring does not silently fall back in this repo. + (MethodInfo cancelTestRun, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(TestRunnerApi)); + + Assert.That(cancelTestRun, Is.Not.Null, "CancelTestRun(string) must resolve on TestRunnerApi"); + Assert.That(isRunActive, Is.Not.Null, "parameterless IsRunActive() must resolve on TestRunnerApi"); + Assert.That(log, Is.Null); + Assert.That(cancelTestRun.IsStatic, Is.True); + Assert.That(isRunActive.IsStatic, Is.True); + } + } +} diff --git a/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs.meta b/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs.meta new file mode 100644 index 000000000..cd2d52892 --- /dev/null +++ b/Assets/Tests/Editor/TestRunnerApiCancelMethodLookupEditModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0e5a3f26f9d4b4a8c7e1d0b3a2f4e69 +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 6dc74d0a3..c6e43c43b 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs @@ -45,15 +45,20 @@ public static async Task ExecutePlayModeTest( // Why not `using`: Dispose must run only after cancel-time stop/restore finishes. // Disposing earlier re-enables domain reload while Test Runner / Play Mode may still be active. DomainReloadDisableScope scope = new DomainReloadDisableScope(); + string runGuid = null; try { - return await ExecuteTestWithEventNotification(TestMode.PlayMode, filter, ct); + return await ExecuteTestWithEventNotification( + TestMode.PlayMode, + filter, + ct, + startedRunGuid => runGuid = startedRunGuid); } catch (OperationCanceledException originalException) { RunTestsCancelStopRestoreResult stopResult = await RunTestsCancelStopRestore.StopAndRestoreAsync( isPlayMode: true, - runGuid: null, + runGuid: runGuid, RunTestsCancelStopRestoreUnityHooks.Resolve()); throw new RunTestsExecutionCanceledException(originalException.CancellationToken, stopResult); } @@ -68,15 +73,20 @@ public static async Task ExecuteEditModeTest( CancellationToken ct) { ct.ThrowIfCancellationRequested(); + string runGuid = null; try { - return await ExecuteTestWithEventNotification(TestMode.EditMode, filter, ct); + return await ExecuteTestWithEventNotification( + TestMode.EditMode, + filter, + ct, + startedRunGuid => runGuid = startedRunGuid); } catch (OperationCanceledException originalException) { RunTestsCancelStopRestoreResult stopResult = await RunTestsCancelStopRestore.StopAndRestoreAsync( isPlayMode: false, - runGuid: null, + runGuid: runGuid, RunTestsCancelStopRestoreUnityHooks.Resolve()); throw new RunTestsExecutionCanceledException(originalException.CancellationToken, stopResult); } @@ -85,7 +95,8 @@ public static async Task ExecuteEditModeTest( private static async Task ExecuteTestWithEventNotification( TestMode testMode, TestExecutionFilter filter, - CancellationToken ct) + CancellationToken ct, + Action onRunStarted) { ct.ThrowIfCancellationRequested(); TaskCompletionSource taskCompletionSource = @@ -106,7 +117,8 @@ private static async Task ExecuteTestWithEventNotificati taskCompletionSource.TrySetResult(result); }; - StartTestExecution(testMode, filter, callback); + string runGuid = StartTestExecution(testMode, filter, callback); + onRunStarted?.Invoke(runGuid); // Without this registration the await below never completes on cancellation, // keeping the TestRunnerApi callback subscription alive forever. using CancellationTokenRegistration cancellationRegistration = @@ -129,15 +141,14 @@ internal static string TrySaveFailureXml(ITestResultAdaptor rawResult) } } - private static void StartTestExecution(TestMode testMode, TestExecutionFilter filter, UnifiedTestCallback callback) + private static string StartTestExecution(TestMode testMode, TestExecutionFilter filter, UnifiedTestCallback callback) { TestRunnerApi testRunnerApi = ScriptableObject.CreateInstance(); callback.SetTestRunnerApi(testRunnerApi); testRunnerApi.RegisterCallbacks(callback); Filter unityFilter = CreateUnityFilter(testMode, filter); - // runGuid is discarded until Option A stores it for CancelTestRun. - _ = testRunnerApi.Execute(new ExecutionSettings(unityFilter)); + return testRunnerApi.Execute(new ExecutionSettings(unityFilter)); } private static Filter CreateUnityFilter(TestMode testMode, TestExecutionFilter filter) diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs index 85defc942..4cbc61f46 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/RunTestsCancelStopRestoreUnityHooks.cs @@ -10,8 +10,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Unity Editor hooks for cancel-time stop/restore. Option B baseline (public API only). - /// Option A (CancelTestRun reflection) can plug into TryCancelTestRun later with B fallback. + /// Unity Editor hooks for cancel-time stop/restore, including Option A CancelTestRun reflection. /// internal static class RunTestsCancelStopRestoreUnityHooks { @@ -27,13 +26,18 @@ internal static RunTestsCancelStopRestoreHooks Resolve() internal static RunTestsCancelStopRestoreHooks CreateDefault() { + TestRunnerApiCancelBridge.EnsureResolved(); + return new RunTestsCancelStopRestoreHooks { - // Why null TryCancelTestRun until Option A is user-approved: TF 1.3.9 exposes - // CancelTestRun only as an internal API, and reflection requires explicit permission. - // When Option A lands, resolve CancelTestRun once, cache it, and fall back here on failure. - TryCancelTestRun = null, - IsRunActive = null, + // Why null when lookup fails: Option A is a superset of Option B. Cached miss + // keeps cancel on Play Mode exit + bounded wait without retrying reflection. + TryCancelTestRun = TestRunnerApiCancelBridge.HasCancelTestRun + ? TestRunnerApiCancelBridge.TryCancelTestRun + : null, + IsRunActive = TestRunnerApiCancelBridge.HasIsRunActive + ? TestRunnerApiCancelBridge.TryIsRunActive + : null, IsPlaying = () => EditorApplication.isPlaying, RequestExitPlayMode = () => { diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs new file mode 100644 index 000000000..71901a186 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs @@ -0,0 +1,131 @@ +#if ULOOP_HAS_TEST_FRAMEWORK +using System; +using System.Reflection; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves TestRunnerApi cancel helpers once via reflection and caches the result. + /// Supports TF 1.3.9 (internal CancelTestRun) and TF 1.4+ (public CancelTestRun). + /// + internal static class TestRunnerApiCancelBridge + { + private static readonly object ResolveLock = new object(); + // Why volatile: double-checked locking outside the lock must observe a completed resolve. + private static volatile bool _resolved; + private static MethodInfo _cancelTestRunMethod; + private static MethodInfo _isRunActiveMethod; + private static string _resolveLog; + + /// + /// Resets cached lookup state for unit tests. + /// + internal static void ResetResolvedStateForTests() + { + lock (ResolveLock) + { + _resolved = false; + _cancelTestRunMethod = null; + _isRunActiveMethod = null; + _resolveLog = null; + } + } + + /// + /// True when CancelTestRun(string) was resolved. + /// + internal static bool HasCancelTestRun + { + get + { + EnsureResolved(); + return _cancelTestRunMethod != null; + } + } + + /// + /// True when parameterless IsRunActive() was resolved. + /// + internal static bool HasIsRunActive + { + get + { + EnsureResolved(); + return _isRunActiveMethod != null; + } + } + + internal static string ResolveLogForTests + { + get + { + EnsureResolved(); + return _resolveLog; + } + } + + /// + /// Invokes CancelTestRun(guid) when available. Returns false when unavailable or invoke fails. + /// + internal static bool TryCancelTestRun(string runGuid) + { + EnsureResolved(); + if (_cancelTestRunMethod == null || string.IsNullOrEmpty(runGuid)) + { + return false; + } + + object result = _cancelTestRunMethod.Invoke(null, new object[] { runGuid }); + return result is bool canceled && canceled; + } + + /// + /// Invokes parameterless IsRunActive() when available. Returns false when unavailable. + /// + internal static bool TryIsRunActive() + { + EnsureResolved(); + if (_isRunActiveMethod == null) + { + return false; + } + + object result = _isRunActiveMethod.Invoke(null, Array.Empty()); + return result is bool isActive && isActive; + } + + /// + /// Resolves methods once. Failures are cached so later calls stay on the Option B path. + /// + internal static void EnsureResolved() + { + if (_resolved) + { + return; + } + + lock (ResolveLock) + { + if (_resolved) + { + return; + } + + (MethodInfo cancel, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(TestRunnerApi)); + _cancelTestRunMethod = cancel; + _isRunActiveMethod = isRunActive; + _resolveLog = log; + _resolved = true; + + if (!string.IsNullOrEmpty(log)) + { + Debug.LogWarning(log); + } + } + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs.meta b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs.meta new file mode 100644 index 000000000..d510d828f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/TestRunnerApiCancelBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9d4f2e15e8c4f3b0a7d6c9e2f1b3a58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs new file mode 100644 index 000000000..e604f3b03 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs @@ -0,0 +1,58 @@ +using System; +using System.Diagnostics; +using System.Reflection; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Pure reflection lookup for TestRunnerApi cancel helpers. Separated so unit tests can + /// exercise TF version differences without Unity assemblies. + /// + internal static class TestRunnerApiCancelMethodLookup + { + /// + /// Resolves CancelTestRun(string) and parameterless IsRunActive() on the given type. + /// + public static (MethodInfo CancelTestRun, MethodInfo IsRunActive, string Log) Resolve(Type testRunnerApiType) + { + Debug.Assert(testRunnerApiType != null, "testRunnerApiType must not be null"); + + // Why Public|NonPublic: TF 1.3.9 keeps CancelTestRun internal; TF 1.4+ (Unity 6000) + // publishes the same CancelTestRun(string) signature. + const BindingFlags flags = + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + MethodInfo cancelTestRun = testRunnerApiType.GetMethod( + "CancelTestRun", + flags, + binder: null, + types: new[] { typeof(string) }, + modifiers: null); + + // Why parameterless only: TF 2.0 changes IsRunActive to IsRunActive(string guid). + // Missing parameterless method is expected there; fall back to Option B polling. + MethodInfo isRunActive = testRunnerApiType.GetMethod( + "IsRunActive", + flags, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + + string log = null; + if (cancelTestRun == null) + { + log = + "TestRunnerApi.CancelTestRun(string) was not found; " + + "run-tests cancel will use the public Play Mode exit fallback."; + } + else if (isRunActive == null) + { + log = + "TestRunnerApi.IsRunActive() was not found; " + + "EditMode cancel will skip active-run polling and rely on CancelTestRun / Play Mode exit."; + } + + return (cancelTestRun, isRunActive, log); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs.meta b/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs.meta new file mode 100644 index 000000000..683046b8e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a8c3e1f04d7b4e2a9f6c5b8d1e0a2f47 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj b/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj index 1f0dcb9a0..9eeff1aba 100644 --- a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj +++ b/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj @@ -16,5 +16,6 @@ + diff --git a/tests/RunTestsExecutionTimeout.UnitTests/TestRunnerApiCancelMethodLookupTests.cs b/tests/RunTestsExecutionTimeout.UnitTests/TestRunnerApiCancelMethodLookupTests.cs new file mode 100644 index 000000000..826ee4f4c --- /dev/null +++ b/tests/RunTestsExecutionTimeout.UnitTests/TestRunnerApiCancelMethodLookupTests.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using NUnit.Framework; +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.UnitTests +{ + /// + /// Pure unit coverage for TestRunnerApi cancel method reflection lookup. + /// + [TestFixture] + public class TestRunnerApiCancelMethodLookupTests + { + [Test] + public void Resolve_WhenInternalCancelAndParameterlessIsRunActiveExist_ShouldFindBoth() + { + // Verifies TF 1.3.9-shaped APIs (internal CancelTestRun + parameterless IsRunActive) resolve. + (MethodInfo cancel, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(Tf139ShapedApi)); + + Assert.That(cancel, Is.Not.Null); + Assert.That(isRunActive, Is.Not.Null); + Assert.That(log, Is.Null); + Assert.That((bool)cancel.Invoke(null, new object[] { "guid" }), Is.True); + Assert.That((bool)isRunActive.Invoke(null, null), Is.True); + } + + [Test] + public void Resolve_WhenCancelIsPublic_ShouldStillFindCancelTestRun() + { + // Verifies TF 1.4+/Unity 6000 public CancelTestRun(string) is found with Public|NonPublic flags. + (MethodInfo cancel, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(Tf14PublicCancelApi)); + + Assert.That(cancel, Is.Not.Null); + Assert.That(isRunActive, Is.Not.Null); + Assert.That(log, Is.Null); + } + + [Test] + public void Resolve_WhenOnlyGuidIsRunActiveExists_ShouldOmitIsRunActiveAndLog() + { + // Verifies TF 2.0 IsRunActive(string) is treated as unavailable parameterless lookup. + (MethodInfo cancel, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(Tf20GuidIsRunActiveApi)); + + Assert.That(cancel, Is.Not.Null); + Assert.That(isRunActive, Is.Null); + Assert.That(log, Does.Contain("IsRunActive()")); + } + + [Test] + public void Resolve_WhenCancelMissing_ShouldReturnNullCancelAndFallbackLog() + { + // Verifies missing CancelTestRun falls back to Option B with a clear log. + (MethodInfo cancel, MethodInfo isRunActive, string log) = + TestRunnerApiCancelMethodLookup.Resolve(typeof(NoCancelApi)); + + Assert.That(cancel, Is.Null); + Assert.That(isRunActive, Is.Not.Null); + Assert.That(log, Does.Contain("CancelTestRun(string)")); + } + + private static class Tf139ShapedApi + { + internal static bool CancelTestRun(string guid) + { + return guid == "guid"; + } + + internal static bool IsRunActive() + { + return true; + } + } + + private static class Tf14PublicCancelApi + { + public static bool CancelTestRun(string guid) + { + return !string.IsNullOrEmpty(guid); + } + + public static bool IsRunActive() + { + return false; + } + } + + private static class Tf20GuidIsRunActiveApi + { + public static bool CancelTestRun(string guid) + { + return !string.IsNullOrEmpty(guid); + } + + public static bool IsRunActive(string guid) + { + return !string.IsNullOrEmpty(guid); + } + } + + private static class NoCancelApi + { + public static bool IsRunActive() + { + return false; + } + } + } +}