From 3c7cf15791a9afbf21108df7b6b0cecd0019b196 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 4 Oct 2026 23:56:49 +0900 Subject: [PATCH 1/2] Move the stop/restore unit tests from the dotnet project to EditMode The dotnet project compiled RunTestsCancelStopRestore.cs, which has used the Unity-only MainThreadSwitcher since #1778, so the project no longer built outside Unity. Drop that source from the csproj and delete its dotnet tests. Three of the six deleted cases had no EditMode equivalent and are ported: a throwing cancel hook in a PlayMode run still falls back to the exit, an EditMode run without a cancel hook skips the cancel even with a run GUID, and the internal waits receive CancellationToken.None. The PlayMode wait case queues four answers so the delay hook is actually reached. --- .../Editor/RunTestsCancelStopRestoreTests.cs | 65 +++++- .../RunTestsCancelStopRestoreTests.cs | 194 ------------------ .../RunTestsExecutionTimeout.UnitTests.csproj | 1 - 3 files changed, 64 insertions(+), 196 deletions(-) delete mode 100644 tests/RunTestsExecutionTimeout.UnitTests/RunTestsCancelStopRestoreTests.cs 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 @@ - From d98db4ca3a91540dc413fa01d43c18850da2c91e Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 4 Oct 2026 23:56:49 +0900 Subject: [PATCH 2/2] Run the run-tests timeout unit tests in pull request and nightly CI No workflow or script ever ran tests/RunTestsExecutionTimeout.UnitTests, so its timeout and cancel-method lookup cases guarded nothing. Add it to the concurrency unit test step next to the other dotnet projects. --- .github/workflows/unity-compile-check-and-test-runner.yml | 1 + .github/workflows/unity-editmode-tests.yml | 1 + 2 files changed, 2 insertions(+) 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 }})