Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// EditMode coverage that the cancel-method lookup resolves against the real TestRunnerApi type.
/// </summary>
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);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,20 @@ public static async Task<SerializableTestResult> 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);
}
Expand All @@ -68,15 +73,20 @@ public static async Task<SerializableTestResult> 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);
}
Expand All @@ -85,7 +95,8 @@ public static async Task<SerializableTestResult> ExecuteEditModeTest(
private static async Task<SerializableTestResult> ExecuteTestWithEventNotification(
TestMode testMode,
TestExecutionFilter filter,
CancellationToken ct)
CancellationToken ct,
Action<string> onRunStarted)
{
ct.ThrowIfCancellationRequested();
TaskCompletionSource<SerializableTestResult> taskCompletionSource =
Expand All @@ -106,7 +117,8 @@ private static async Task<SerializableTestResult> 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 =
Expand All @@ -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<TestRunnerApi>();
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// 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.
/// </summary>
internal static class RunTestsCancelStopRestoreUnityHooks
{
Expand All @@ -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 = () =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Resolves TestRunnerApi cancel helpers once via reflection and caches the result.
/// Supports TF 1.3.9 (internal CancelTestRun) and TF 1.4+ (public CancelTestRun).
/// </summary>
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;

/// <summary>
/// Resets cached lookup state for unit tests.
/// </summary>
internal static void ResetResolvedStateForTests()
{
lock (ResolveLock)
{
_resolved = false;
_cancelTestRunMethod = null;
_isRunActiveMethod = null;
_resolveLog = null;
}
}

/// <summary>
/// True when CancelTestRun(string) was resolved.
/// </summary>
internal static bool HasCancelTestRun
{
get
{
EnsureResolved();
return _cancelTestRunMethod != null;
}
}

/// <summary>
/// True when parameterless IsRunActive() was resolved.
/// </summary>
internal static bool HasIsRunActive
{
get
{
EnsureResolved();
return _isRunActiveMethod != null;
}
}

internal static string ResolveLogForTests
{
get
{
EnsureResolved();
return _resolveLog;
}
}

/// <summary>
/// Invokes CancelTestRun(guid) when available. Returns false when unavailable or invoke fails.
/// </summary>
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;
}

/// <summary>
/// Invokes parameterless IsRunActive() when available. Returns false when unavailable.
/// </summary>
internal static bool TryIsRunActive()
{
EnsureResolved();
if (_isRunActiveMethod == null)
{
return false;
}

object result = _isRunActiveMethod.Invoke(null, Array.Empty<object>());
return result is bool isActive && isActive;
}

/// <summary>
/// Resolves methods once. Failures are cached so later calls stay on the Option B path.
/// </summary>
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Diagnostics;
using System.Reflection;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Pure reflection lookup for TestRunnerApi cancel helpers. Separated so unit tests can
/// exercise TF version differences without Unity assemblies.
/// </summary>
internal static class TestRunnerApiCancelMethodLookup
{
/// <summary>
/// Resolves CancelTestRun(string) and parameterless IsRunActive() on the given type.
/// </summary>
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);
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
<ItemGroup>
<Compile Include="../../Packages/src/Editor/FirstPartyTools/RunTests/RunTestsExecutionTimeout.cs" Link="Production/RunTestsExecutionTimeout.cs" />
<Compile Include="../../Packages/src/Editor/FirstPartyTools/RunTests/RunTestsCancelStopRestore.cs" Link="Production/RunTestsCancelStopRestore.cs" />
<Compile Include="../../Packages/src/Editor/FirstPartyTools/RunTests/TestRunnerApiCancelMethodLookup.cs" Link="Production/TestRunnerApiCancelMethodLookup.cs" />
</ItemGroup>
</Project>
Loading
Loading