Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 74 additions & 39 deletions Tests/AppTestHelpers/AppTestHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -108,7 +109,8 @@ public AppTestHelper (string driverName, TextWriter? logWriter = null, TimeSpan?
}

/// <summary>
/// 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.
/// </summary>
internal AppTestHelper (Func<IRunnable> runnableBuilder, int width, int height, string driverName, TextWriter? logWriter = null, TimeSpan? timeout = null)
{
Expand All @@ -120,7 +122,7 @@ internal AppTestHelper (Func<IRunnable> 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!);

Expand All @@ -140,27 +142,36 @@ internal AppTestHelper (Func<IRunnable> 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)
Expand Down Expand Up @@ -321,29 +332,42 @@ public AppTestHelper WaitIteration (Action<IApplication>? 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;
Expand Down Expand Up @@ -415,6 +439,17 @@ public AppTestHelper AnsiScreenShot (string title, TextWriter? writer) =>
writer?.WriteLine (text);
});

/// <summary>
/// Cancels the linked token observed by the running application.
/// </summary>
/// <returns></returns>
public AppTestHelper CancelRun ()
{
_ansiInput.ExternalCancellationTokenSource?.Cancel ();

return this;
}

/// <summary>
/// Stops the application and waits for the background thread to exit.
/// </summary>
Expand Down
37 changes: 29 additions & 8 deletions Tests/AppTestHelpers/With.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,26 @@ public static class With
/// <param name="height"></param>
/// <param name="driverName"></param>
/// <param name="logWriter"></param>
/// <param name="timeout"></param>
/// <returns></returns>
public static AppTestHelper A<T> (int width, int height, string driverName, TextWriter? logWriter = null) where T : IRunnable, new()
public static AppTestHelper A<T> (
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);
}

/// <summary>
Expand All @@ -31,11 +43,20 @@ public static class With
/// <param name="height"></param>
/// <param name="driverName"></param>
/// <param name="logWriter"></param>
/// <param name="timeout"></param>
/// <returns></returns>
public static AppTestHelper A (Func<IRunnable> runnableFactory, int width, int height, string driverName, TextWriter? logWriter = null)
public static AppTestHelper A (
Func<IRunnable> 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);
}

/// <summary>
/// The global timeout to allow for any given application to run for before shutting down.
/// </summary>
Expand Down
18 changes: 12 additions & 6 deletions Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Window> (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]
Expand Down Expand Up @@ -317,4 +323,4 @@ public void WithTextField_UpdatesText (string d)

//Assert.Equal ("Hello", textField.Text);
}
}
}
30 changes: 30 additions & 0 deletions Tests/IntegrationTests/FluentTests/TestContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Window> (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<Window> (40, 10, DriverRegistry.Names.ANSI, _out);
InvalidOperationException expectedException = new ("Expected test failure");

InvalidOperationException exception = Assert.Throws<InvalidOperationException> (
() => 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)
Expand Down
Loading