diff --git a/Tests/AppTestHelpers/AppTestHelper.cs b/Tests/AppTestHelpers/AppTestHelper.cs index 2056ca44df..d68aaf8268 100644 --- a/Tests/AppTestHelpers/AppTestHelper.cs +++ b/Tests/AppTestHelpers/AppTestHelper.cs @@ -1,5 +1,6 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Drawing; +using System.Runtime.ExceptionServices; using System.Text; using Microsoft.Extensions.Logging; using Terminal.Gui.Time; @@ -108,7 +109,8 @@ public AppTestHelper (string driverName, TextWriter? logWriter = null, TimeSpan? } /// - /// Constructor for tests that need to run the application with Application.Run. + /// Constructor for tests that need to run the application with IApplication.RunAsync. + /// The runnable observes the helper's linked cancellation token, including timeout cancellation. /// internal AppTestHelper (Func runnableBuilder, int width, int height, string driverName, TextWriter? logWriter = null, TimeSpan? timeout = null) { @@ -120,7 +122,7 @@ internal AppTestHelper (Func runnableBuilder, int width, int height, CommonInit (width, height, timeout); // Start the application in a background thread - _runTask = Task.Run (() => + _runTask = Task.Run (async () => { _loggerScope = Logging.PushLogger (_testLogger!); @@ -140,27 +142,36 @@ internal AppTestHelper (Func runnableBuilder, int width, int height, _booting.Release (); } - if (App is { Initialized: true }) + if (App is { Initialized: true } app) { - IRunnable runnable = runnableBuilder (); + IRunnable? runnable = null; - runnable.IsRunningChanged += (s, e) => - { - if (!e.Value) - { - Finished = true; - } - }; - App?.Run (runnable); // This will block, but it's on a background thread now + try + { + runnable = runnableBuilder (); - if (runnable is View runnableView) + runnable.IsRunningChanged += (_, e) => + { + if (!e.Value) + { + Finished = true; + } + }; + + CancellationToken helperCancellationToken = _ansiInput.ExternalCancellationTokenSource!.Token; + await app.RunAsync (runnable, helperCancellationToken); + } + finally { - runnableView.Dispose (); + if (runnable is View runnableView) + { + runnableView.Dispose (); + } + + //Logging.Trace ("Application.Run completed"); + app.Dispose (); + _runCancellationTokenSource.Cancel (); } - - //Logging.Trace ("Application.Run completed"); - App?.Dispose (); - _runCancellationTokenSource.Cancel (); } } catch (OperationCanceledException) @@ -321,29 +332,42 @@ public AppTestHelper WaitIteration (Action? action = null) { action = app => { }; } + CancellationTokenSource ctsActionCompleted = new (); + Exception? actionException = null; App?.Invoke (app => - { - try - { - action (app); - - //Logging.Trace ("Action completed"); - ctsActionCompleted.Cancel (); - } - catch (Exception e) - { - Logging.Warning ($"Action failed with exception: {e}"); - _backgroundException = e; - _ansiInput.ExternalCancellationTokenSource?.Cancel (); - } - }); - - // Blocks until either the token or the hardStopToken is cancelled. - // With linked tokens, we only need to wait on _runCancellationTokenSource and ctsLocal - // ExternalCancellationTokenSource is redundant because it's linked to _runCancellationTokenSource - WaitHandle.WaitAny ([_runCancellationTokenSource.Token.WaitHandle, ctsActionCompleted.Token.WaitHandle]); + { + try + { + action (app); + + //Logging.Trace ("Action completed"); + ctsActionCompleted.Cancel (); + } + catch (Exception e) + { + Logging.Warning ($"Action failed with exception: {e}"); + _backgroundException = e; + actionException = e; + _ansiInput.ExternalCancellationTokenSource?.Cancel (); + } + }); + + // Blocks until either the action completes, the run stops, or the timeout/hard-stop token is cancelled. + WaitHandle [] waitHandles = + [ + _runCancellationTokenSource.Token.WaitHandle, + ctsActionCompleted.Token.WaitHandle, + _ansiInput.ExternalCancellationTokenSource!.Token.WaitHandle + ]; + + WaitHandle.WaitAny (waitHandles); + + if (actionException is { }) + { + ExceptionDispatchInfo.Capture (actionException).Throw (); + } // Logging.Trace ($"Return from WaitIteration"); return this; @@ -415,6 +439,17 @@ public AppTestHelper AnsiScreenShot (string title, TextWriter? writer) => writer?.WriteLine (text); }); + /// + /// Cancels the linked token observed by the running application. + /// + /// + public AppTestHelper CancelRun () + { + _ansiInput.ExternalCancellationTokenSource?.Cancel (); + + return this; + } + /// /// Stops the application and waits for the background thread to exit. /// diff --git a/Tests/AppTestHelpers/With.cs b/Tests/AppTestHelpers/With.cs index acfeac4274..73904f89e3 100644 --- a/Tests/AppTestHelpers/With.cs +++ b/Tests/AppTestHelpers/With.cs @@ -13,14 +13,26 @@ public static class With /// /// /// + /// /// - public static AppTestHelper A (int width, int height, string driverName, TextWriter? logWriter = null) where T : IRunnable, new() + public static AppTestHelper A ( + int width, + int height, + string driverName, + TextWriter? logWriter = null, + TimeSpan? timeout = null + ) where T : IRunnable, new () { - return new (() => new T () - { - //Id = $"{typeof (T).Name}" - }, width, height, - driverName, logWriter, Timeout); + return new ( + () => new T () + { + //Id = $"{typeof (T).Name}" + }, + width, + height, + driverName, + logWriter, + timeout ?? Timeout); } /// @@ -31,11 +43,20 @@ public static class With /// /// /// + /// /// - public static AppTestHelper A (Func runnableFactory, int width, int height, string driverName, TextWriter? logWriter = null) + public static AppTestHelper A ( + Func runnableFactory, + int width, + int height, + string driverName, + TextWriter? logWriter = null, + TimeSpan? timeout = null + ) { - return new (runnableFactory, width, height, driverName, logWriter, Timeout); + return new (runnableFactory, width, height, driverName, logWriter, timeout ?? Timeout); } + /// /// The global timeout to allow for any given application to run for before shutting down. /// diff --git a/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs b/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs index a3965ca35e..2a039ba2b6 100644 --- a/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs +++ b/Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs @@ -14,12 +14,18 @@ public class TestContextKeyEventTests (ITestOutputHelper outputHelper) : TestsAl [MemberData (nameof (GetAllDriverNames))] public void QuitKey_ViaApplication_Stops (string d) { + IRunnable? top = null; + using AppTestHelper helper = With.A (40, 10, d) - .Then ((app) => - { - app?.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit)); - Assert.False (app!.TopRunnable!.IsRunning); - }); + .Then (app => + { + top = app!.TopRunnable; + app.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit)); + }); + + Assert.True ( + SpinWait.SpinUntil (() => top is { IsRunning: false }, TimeSpan.FromSeconds (5)), + "TopRunnable did not stop within timeout."); } [Theory] @@ -317,4 +323,4 @@ public void WithTextField_UpdatesText (string d) //Assert.Equal ("Hello", textField.Text); } -} \ No newline at end of file +} diff --git a/Tests/IntegrationTests/FluentTests/TestContextTests.cs b/Tests/IntegrationTests/FluentTests/TestContextTests.cs index 3ae7aa57f0..a41bc261a6 100644 --- a/Tests/IntegrationTests/FluentTests/TestContextTests.cs +++ b/Tests/IntegrationTests/FluentTests/TestContextTests.cs @@ -99,6 +99,36 @@ public void With_Starts_Stops_Without_Error (string d) // No actual assertions are needed — if no exceptions are thrown, it's working } + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void RunAsync_Cancellation_After_Boot_Stops_Application () + { + // Copilot + using AppTestHelper helper = With.A (40, 10, DriverRegistry.Names.ANSI, _out); + helper.CancelRun (); + + Assert.True ( + SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)), + "AppTestHelper did not finish after cancellation."); + } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void Then_Exception_HardStops_Without_Hanging () + { + // Copilot + using AppTestHelper helper = With.A (40, 10, DriverRegistry.Names.ANSI, _out); + InvalidOperationException expectedException = new ("Expected test failure"); + + InvalidOperationException exception = Assert.Throws ( + () => helper.Then (_ => throw expectedException)); + + Assert.Same (expectedException, exception); + Assert.True ( + SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)), + "AppTestHelper did not finish after action failure."); + } + [Theory] [MemberData (nameof (GetAllDriverNames))] public void With_Without_Stop_Still_Cleans_Up (string d)