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 @@ -