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