diff --git a/.github/workflows/unity-compile-check-and-test-runner.yml b/.github/workflows/unity-compile-check-and-test-runner.yml
index 11931e5a19..9b529173f4 100644
--- a/.github/workflows/unity-compile-check-and-test-runner.yml
+++ b/.github/workflows/unity-compile-check-and-test-runner.yml
@@ -83,6 +83,7 @@ jobs:
dotnet test tests/DynamicCodeExecutionScheduler.UnitTests/DynamicCodeExecutionScheduler.UnitTests.csproj --configuration Release
dotnet test tests/SharedRoslynCompilerWorker.UnitTests/SharedRoslynCompilerWorker.UnitTests.csproj --configuration Release
dotnet test tests/OutputFileRetention.UnitTests/OutputFileRetention.UnitTests.csproj --configuration Release
+ dotnet test tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj --configuration Release
- name: Cache Library folder
if: env.HAS_UNITY_LICENSE == 'true'
diff --git a/.github/workflows/unity-editmode-tests.yml b/.github/workflows/unity-editmode-tests.yml
index 7e81ac5893..6ce5040e7c 100644
--- a/.github/workflows/unity-editmode-tests.yml
+++ b/.github/workflows/unity-editmode-tests.yml
@@ -32,6 +32,7 @@ jobs:
dotnet test tests/DynamicCodeExecutionScheduler.UnitTests/DynamicCodeExecutionScheduler.UnitTests.csproj --configuration Release
dotnet test tests/SharedRoslynCompilerWorker.UnitTests/SharedRoslynCompilerWorker.UnitTests.csproj --configuration Release
dotnet test tests/OutputFileRetention.UnitTests/OutputFileRetention.UnitTests.csproj --configuration Release
+ dotnet test tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj --configuration Release
editmode-tests:
name: EditMode Tests (${{ matrix.unity-version }})
diff --git a/Assets/Tests/Editor/RunTestsCancelStopRestoreTests.cs b/Assets/Tests/Editor/RunTestsCancelStopRestoreTests.cs
index 4816238263..71ff6f7ec9 100644
--- a/Assets/Tests/Editor/RunTestsCancelStopRestoreTests.cs
+++ b/Assets/Tests/Editor/RunTestsCancelStopRestoreTests.cs
@@ -207,6 +207,66 @@ public async Task StopAndRestoreAsync_WhenAHookThrowsOutsideItsGuard_SwallowsThe
Assert.That(result.DegradationNote, Does.StartWith("Stop/restore failed unexpectedly"));
}
+ ///
+ /// Verifies a throwing cancel hook during a PlayMode run still falls back to requesting and confirming the exit.
+ ///
+ [Test]
+ public async Task StopAndRestoreAsync_PlayModeWhenTheCancelHookThrows_FallsBackToTheExit()
+ {
+ _hooks.CancelException = new InvalidOperationException("cancel failed");
+ _hooks.PlayingAnswers.Enqueue(true);
+ _hooks.PlayingAnswers.Enqueue(true);
+ _hooks.PlayingAnswers.Enqueue(false);
+
+ RunTestsCancelStopRestoreResult result = await StopAndRestore(isPlayMode: true, RunGuid);
+
+ Assert.That(_hooks.CancelGuids, Is.EqualTo(new List { RunGuid }));
+ Assert.That(result.TestRunCancelAttempted, Is.True);
+ Assert.That(result.TestRunCancelSucceeded, Is.False);
+ Assert.That(_hooks.ExitRequestCount, Is.EqualTo(1));
+ Assert.That(result.PlayModeExitConfirmed, Is.True);
+ Assert.That(_hooks.Warnings.Count, Is.EqualTo(1));
+ Assert.That(_hooks.Warnings[0], Does.StartWith("TryCancelTestRun failed and was ignored"));
+ }
+
+ ///
+ /// Verifies an EditMode run without a cancel hook skips the cancel even with a run GUID, warns about the
+ /// EditMode job, and does not fall into the catch-all.
+ ///
+ [Test]
+ public async Task StopAndRestoreAsync_EditModeWithoutACancelHook_SkipsTheCancelAndWarnsAboutTheEditModeJob()
+ {
+ _hooks.CancelHookAvailable = false;
+
+ RunTestsCancelStopRestoreResult result = await StopAndRestore(isPlayMode: false, RunGuid);
+
+ Assert.That(_hooks.CancelGuids, Is.Empty);
+ Assert.That(result.TestRunCancelAttempted, Is.False);
+ Assert.That(_hooks.ExitRequestCount, Is.EqualTo(0));
+ Assert.That(
+ result.DegradationNote,
+ Does.StartWith("Unity Test Framework 1.3.9 has no public API to cancel an in-flight test job; " +
+ "an EditMode job may still be running until it finishes. "));
+ Assert.That(_hooks.Warnings, Is.Empty);
+ }
+
+ ///
+ /// Verifies the internal stop-wait delays receive an uncancelable token instead of the caller's canceled one.
+ ///
+ [Test]
+ public async Task StopAndRestoreAsync_InternalWaits_UseAnUncancelableToken()
+ {
+ _hooks.PlayingAnswers.Enqueue(true);
+ _hooks.PlayingAnswers.Enqueue(true);
+ _hooks.PlayingAnswers.Enqueue(true);
+ _hooks.PlayingAnswers.Enqueue(false);
+
+ await StopAndRestore(isPlayMode: true, string.Empty);
+
+ Assert.That(_hooks.DelayTokens.Count, Is.GreaterThan(0));
+ Assert.That(_hooks.DelayTokens, Is.All.EqualTo(CancellationToken.None));
+ }
+
///
/// Verifies a result built without a note never exposes null to the timeout message.
///
@@ -235,6 +295,7 @@ private Task StopAndRestore(bool isPlayMode, st
private sealed class ScriptedHooks
{
internal bool CancelResult { get; set; }
+ internal bool CancelHookAvailable { get; set; } = true;
internal Exception CancelException { get; set; }
internal Exception ExitException { get; set; }
internal Exception PlayingException { get; set; }
@@ -244,6 +305,7 @@ private sealed class ScriptedHooks
internal bool RunActiveDefault { get; set; }
internal List CancelGuids { get; } = new List();
internal List Delays { get; } = new List();
+ internal List DelayTokens { get; } = new List();
internal List Warnings { get; } = new List();
internal int ExitRequestCount { get; private set; }
@@ -251,7 +313,7 @@ internal RunTestsCancelStopRestoreHooks ToHooks()
{
return new RunTestsCancelStopRestoreHooks
{
- TryCancelTestRun = TryCancelTestRun,
+ TryCancelTestRun = CancelHookAvailable ? TryCancelTestRun : null,
IsRunActive = () => RunActiveAnswers.Count > 0 ? RunActiveAnswers.Dequeue() : RunActiveDefault,
IsPlaying = IsPlaying,
RequestExitPlayMode = RequestExitPlayMode,
@@ -294,6 +356,7 @@ private Task DelayAsync(int milliseconds, CancellationToken ct)
{
// Completes immediately so the bounded polling loop runs synchronously without wall-clock waits.
Delays.Add(milliseconds);
+ DelayTokens.Add(ct);
return Task.CompletedTask;
}
}
diff --git a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsCancelStopRestoreTests.cs b/tests/RunTestsExecutionTimeout.UnitTests/RunTestsCancelStopRestoreTests.cs
deleted file mode 100644
index f5d2d12c15..0000000000
--- a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsCancelStopRestoreTests.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using NUnit.Framework;
-using io.github.hatayama.UnityCliLoop.FirstPartyTools;
-
-namespace io.github.hatayama.UnityCliLoop.UnitTests
-{
- ///
- /// Pure unit coverage for cancel-time Test Runner stop and PlayMode restore orchestration.
- ///
- [TestFixture]
- public class RunTestsCancelStopRestoreTests
- {
- [Test]
- public async Task StopAndRestoreAsync_WhenPlayModeIsActive_ShouldExitThenConfirmBeforeReturning()
- {
- // Verifies PlayMode cancel path requests exit and waits until not playing.
- int playingChecks = 0;
- bool exitRequested = false;
- RecordingHooks hooks = new(
- isPlaying: () =>
- {
- playingChecks++;
- return !exitRequested;
- },
- requestExitPlayMode: () => exitRequested = true);
-
- RunTestsCancelStopRestoreResult result = await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: true,
- runGuid: null,
- hooks.Create(),
- stopWaitTimeoutMilliseconds: 1000,
- pollIntervalMilliseconds: 1);
-
- Assert.That(exitRequested, Is.True);
- Assert.That(result.PlayModeExitRequested, Is.True);
- Assert.That(result.PlayModeExitConfirmed, Is.True);
- Assert.That(result.StopWaitTimedOut, Is.False);
- Assert.That(playingChecks, Is.GreaterThan(0));
- Assert.That(result.DegradationNote, Does.Contain("Play Mode exit was requested and confirmed"));
- Assert.That(result.DegradationNote, Does.Not.Contain("no public API"));
- }
-
- [Test]
- public async Task StopAndRestoreAsync_WhenPlayModeExitNeverConfirms_ShouldTimeOutWithoutThrowing()
- {
- // Verifies the upper bound frees cancel handling when PlayMode exit never completes.
- RecordingHooks hooks = new(
- isPlaying: () => true,
- requestExitPlayMode: () => { });
-
- RunTestsCancelStopRestoreResult result = await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: true,
- runGuid: null,
- hooks.Create(),
- stopWaitTimeoutMilliseconds: 5,
- pollIntervalMilliseconds: 1);
-
- Assert.That(result.PlayModeExitRequested, Is.True);
- Assert.That(result.PlayModeExitConfirmed, Is.False);
- Assert.That(result.StopWaitTimedOut, Is.True);
- Assert.That(result.DegradationNote, Does.Contain("not confirmed"));
- }
-
- [Test]
- public async Task StopAndRestoreAsync_WhenTryCancelThrows_ShouldFallBackToPlayModeExit()
- {
- // Verifies Option A style cancel failures degrade to the public PlayMode exit path.
- bool exitRequested = false;
- RecordingHooks hooks = new(
- isPlaying: () => !exitRequested,
- requestExitPlayMode: () => exitRequested = true,
- tryCancelTestRun: _ => throw new InvalidOperationException("lookup failed"));
-
- RunTestsCancelStopRestoreResult result = await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: true,
- runGuid: "run-guid",
- hooks.Create(),
- stopWaitTimeoutMilliseconds: 1000,
- pollIntervalMilliseconds: 1);
-
- Assert.That(result.TestRunCancelAttempted, Is.True);
- Assert.That(result.TestRunCancelSucceeded, Is.False);
- Assert.That(exitRequested, Is.True);
- Assert.That(result.PlayModeExitConfirmed, Is.True);
- Assert.That(hooks.Warnings.Count, Is.EqualTo(1));
- }
-
- [Test]
- public async Task StopAndRestoreAsync_WhenDelayThrows_ShouldReturnDegradedResultWithoutThrowing()
- {
- // Verifies the no-throw contract: unexpected hook failures become degradation notes.
- RecordingHooks hooks = new(
- isPlaying: () => true,
- requestExitPlayMode: () => { },
- delayAsync: (_, _) => throw new InvalidOperationException("delay failed"));
-
- RunTestsCancelStopRestoreResult result = await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: true,
- runGuid: null,
- hooks.Create(),
- stopWaitTimeoutMilliseconds: 1000,
- pollIntervalMilliseconds: 1);
-
- Assert.That(result.DegradationNote, Does.Contain("failed unexpectedly"));
- Assert.That(hooks.Warnings.Count, Is.EqualTo(1));
- }
-
- [Test]
- public async Task StopAndRestoreAsync_WhenEditModeAndCancelUnavailable_ShouldSkipPlayModeExit()
- {
- // Verifies EditMode Option B path does not touch PlayMode and documents job-stop limits.
- bool exitRequested = false;
- RecordingHooks hooks = new(
- isPlaying: () => false,
- requestExitPlayMode: () => exitRequested = true);
-
- RunTestsCancelStopRestoreResult result = await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: false,
- runGuid: null,
- hooks.Create());
-
- Assert.That(exitRequested, Is.False);
- Assert.That(result.PlayModeExitRequested, Is.False);
- Assert.That(result.DegradationNote, Does.Contain("no public API"));
- Assert.That(result.DegradationNote, Does.Contain("EditMode"));
- }
-
- [Test]
- public async Task StopAndRestoreAsync_ShouldNotObserveAlreadyCanceledTokensForInternalWaits()
- {
- // Verifies internal polling uses CancellationToken.None rather than a canceled execution token.
- using CancellationTokenSource canceled = new();
- canceled.Cancel();
- CancellationToken observedToken = CancellationToken.None;
- bool exitRequested = false;
- RecordingHooks hooks = new(
- isPlaying: () => !exitRequested,
- requestExitPlayMode: () => exitRequested = true,
- delayAsync: (milliseconds, ct) =>
- {
- observedToken = ct;
- ct.ThrowIfCancellationRequested();
- return Task.CompletedTask;
- });
-
- await RunTestsCancelStopRestore.StopAndRestoreAsync(
- isPlayMode: true,
- runGuid: null,
- hooks.Create(),
- stopWaitTimeoutMilliseconds: 1000,
- pollIntervalMilliseconds: 1);
-
- Assert.That(observedToken.IsCancellationRequested, Is.False);
- Assert.That(canceled.IsCancellationRequested, Is.True);
- }
-
- private sealed class RecordingHooks
- {
- private readonly Func _isPlaying;
- private readonly Action _requestExitPlayMode;
- private readonly Func _tryCancelTestRun;
- private readonly Func _delayAsync;
-
- public RecordingHooks(
- Func isPlaying,
- Action requestExitPlayMode,
- Func tryCancelTestRun = null,
- Func delayAsync = null)
- {
- _isPlaying = isPlaying;
- _requestExitPlayMode = requestExitPlayMode;
- _tryCancelTestRun = tryCancelTestRun;
- _delayAsync = delayAsync ?? ((_, _) => Task.CompletedTask);
- }
-
- public System.Collections.Generic.List Warnings { get; } = new();
-
- public RunTestsCancelStopRestoreHooks Create()
- {
- return new RunTestsCancelStopRestoreHooks
- {
- TryCancelTestRun = _tryCancelTestRun,
- IsRunActive = null,
- IsPlaying = _isPlaying,
- RequestExitPlayMode = _requestExitPlayMode,
- DelayAsync = _delayAsync,
- LogWarning = warning => Warnings.Add(warning)
- };
- }
- }
- }
-}
diff --git a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj b/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj
index 9eeff1aba1..d4e524620d 100644
--- a/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj
+++ b/tests/RunTestsExecutionTimeout.UnitTests/RunTestsExecutionTimeout.UnitTests.csproj
@@ -15,7 +15,6 @@
-