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)