Skip to content

Commit 1301e51

Browse files
authored
Merge pull request #5267 from gui-cs/copilot/refactor-apptesthelper-runasync
2 parents 5cc4b7f + e4a7ca8 commit 1301e51

4 files changed

Lines changed: 145 additions & 53 deletions

File tree

Tests/AppTestHelpers/AppTestHelper.cs

Lines changed: 74 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
using System.Diagnostics;
1+
using System.Diagnostics;
22
using System.Drawing;
3+
using System.Runtime.ExceptionServices;
34
using System.Text;
45
using Microsoft.Extensions.Logging;
56
using Terminal.Gui.Time;
@@ -108,7 +109,8 @@ public AppTestHelper (string driverName, TextWriter? logWriter = null, TimeSpan?
108109
}
109110

110111
/// <summary>
111-
/// Constructor for tests that need to run the application with Application.Run.
112+
/// Constructor for tests that need to run the application with IApplication.RunAsync.
113+
/// The runnable observes the helper's linked cancellation token, including timeout cancellation.
112114
/// </summary>
113115
internal AppTestHelper (Func<IRunnable> runnableBuilder, int width, int height, string driverName, TextWriter? logWriter = null, TimeSpan? timeout = null)
114116
{
@@ -120,7 +122,7 @@ internal AppTestHelper (Func<IRunnable> runnableBuilder, int width, int height,
120122
CommonInit (width, height, timeout);
121123

122124
// Start the application in a background thread
123-
_runTask = Task.Run (() =>
125+
_runTask = Task.Run (async () =>
124126
{
125127
_loggerScope = Logging.PushLogger (_testLogger!);
126128

@@ -140,27 +142,36 @@ internal AppTestHelper (Func<IRunnable> runnableBuilder, int width, int height,
140142
_booting.Release ();
141143
}
142144

143-
if (App is { Initialized: true })
145+
if (App is { Initialized: true } app)
144146
{
145-
IRunnable runnable = runnableBuilder ();
147+
IRunnable? runnable = null;
146148

147-
runnable.IsRunningChanged += (s, e) =>
148-
{
149-
if (!e.Value)
150-
{
151-
Finished = true;
152-
}
153-
};
154-
App?.Run (runnable); // This will block, but it's on a background thread now
149+
try
150+
{
151+
runnable = runnableBuilder ();
155152

156-
if (runnable is View runnableView)
153+
runnable.IsRunningChanged += (_, e) =>
154+
{
155+
if (!e.Value)
156+
{
157+
Finished = true;
158+
}
159+
};
160+
161+
CancellationToken helperCancellationToken = _ansiInput.ExternalCancellationTokenSource!.Token;
162+
await app.RunAsync (runnable, helperCancellationToken);
163+
}
164+
finally
157165
{
158-
runnableView.Dispose ();
166+
if (runnable is View runnableView)
167+
{
168+
runnableView.Dispose ();
169+
}
170+
171+
//Logging.Trace ("Application.Run completed");
172+
app.Dispose ();
173+
_runCancellationTokenSource.Cancel ();
159174
}
160-
161-
//Logging.Trace ("Application.Run completed");
162-
App?.Dispose ();
163-
_runCancellationTokenSource.Cancel ();
164175
}
165176
}
166177
catch (OperationCanceledException)
@@ -321,29 +332,42 @@ public AppTestHelper WaitIteration (Action<IApplication>? action = null)
321332
{
322333
action = app => { };
323334
}
335+
324336
CancellationTokenSource ctsActionCompleted = new ();
337+
Exception? actionException = null;
325338

326339
App?.Invoke (app =>
327-
{
328-
try
329-
{
330-
action (app);
331-
332-
//Logging.Trace ("Action completed");
333-
ctsActionCompleted.Cancel ();
334-
}
335-
catch (Exception e)
336-
{
337-
Logging.Warning ($"Action failed with exception: {e}");
338-
_backgroundException = e;
339-
_ansiInput.ExternalCancellationTokenSource?.Cancel ();
340-
}
341-
});
342-
343-
// Blocks until either the token or the hardStopToken is cancelled.
344-
// With linked tokens, we only need to wait on _runCancellationTokenSource and ctsLocal
345-
// ExternalCancellationTokenSource is redundant because it's linked to _runCancellationTokenSource
346-
WaitHandle.WaitAny ([_runCancellationTokenSource.Token.WaitHandle, ctsActionCompleted.Token.WaitHandle]);
340+
{
341+
try
342+
{
343+
action (app);
344+
345+
//Logging.Trace ("Action completed");
346+
ctsActionCompleted.Cancel ();
347+
}
348+
catch (Exception e)
349+
{
350+
Logging.Warning ($"Action failed with exception: {e}");
351+
_backgroundException = e;
352+
actionException = e;
353+
_ansiInput.ExternalCancellationTokenSource?.Cancel ();
354+
}
355+
});
356+
357+
// Blocks until either the action completes, the run stops, or the timeout/hard-stop token is cancelled.
358+
WaitHandle [] waitHandles =
359+
[
360+
_runCancellationTokenSource.Token.WaitHandle,
361+
ctsActionCompleted.Token.WaitHandle,
362+
_ansiInput.ExternalCancellationTokenSource!.Token.WaitHandle
363+
];
364+
365+
WaitHandle.WaitAny (waitHandles);
366+
367+
if (actionException is { })
368+
{
369+
ExceptionDispatchInfo.Capture (actionException).Throw ();
370+
}
347371

348372
// Logging.Trace ($"Return from WaitIteration");
349373
return this;
@@ -415,6 +439,17 @@ public AppTestHelper AnsiScreenShot (string title, TextWriter? writer) =>
415439
writer?.WriteLine (text);
416440
});
417441

442+
/// <summary>
443+
/// Cancels the linked token observed by the running application.
444+
/// </summary>
445+
/// <returns></returns>
446+
public AppTestHelper CancelRun ()
447+
{
448+
_ansiInput.ExternalCancellationTokenSource?.Cancel ();
449+
450+
return this;
451+
}
452+
418453
/// <summary>
419454
/// Stops the application and waits for the background thread to exit.
420455
/// </summary>

Tests/AppTestHelpers/With.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,26 @@ public static class With
1313
/// <param name="height"></param>
1414
/// <param name="driverName"></param>
1515
/// <param name="logWriter"></param>
16+
/// <param name="timeout"></param>
1617
/// <returns></returns>
17-
public static AppTestHelper A<T> (int width, int height, string driverName, TextWriter? logWriter = null) where T : IRunnable, new()
18+
public static AppTestHelper A<T> (
19+
int width,
20+
int height,
21+
string driverName,
22+
TextWriter? logWriter = null,
23+
TimeSpan? timeout = null
24+
) where T : IRunnable, new ()
1825
{
19-
return new (() => new T ()
20-
{
21-
//Id = $"{typeof (T).Name}"
22-
}, width, height,
23-
driverName, logWriter, Timeout);
26+
return new (
27+
() => new T ()
28+
{
29+
//Id = $"{typeof (T).Name}"
30+
},
31+
width,
32+
height,
33+
driverName,
34+
logWriter,
35+
timeout ?? Timeout);
2436
}
2537

2638
/// <summary>
@@ -31,11 +43,20 @@ public static class With
3143
/// <param name="height"></param>
3244
/// <param name="driverName"></param>
3345
/// <param name="logWriter"></param>
46+
/// <param name="timeout"></param>
3447
/// <returns></returns>
35-
public static AppTestHelper A (Func<IRunnable> runnableFactory, int width, int height, string driverName, TextWriter? logWriter = null)
48+
public static AppTestHelper A (
49+
Func<IRunnable> runnableFactory,
50+
int width,
51+
int height,
52+
string driverName,
53+
TextWriter? logWriter = null,
54+
TimeSpan? timeout = null
55+
)
3656
{
37-
return new (runnableFactory, width, height, driverName, logWriter, Timeout);
57+
return new (runnableFactory, width, height, driverName, logWriter, timeout ?? Timeout);
3858
}
59+
3960
/// <summary>
4061
/// The global timeout to allow for any given application to run for before shutting down.
4162
/// </summary>

Tests/IntegrationTests/FluentTests/TestContextKeyEventTests.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@ public class TestContextKeyEventTests (ITestOutputHelper outputHelper) : TestsAl
1414
[MemberData (nameof (GetAllDriverNames))]
1515
public void QuitKey_ViaApplication_Stops (string d)
1616
{
17+
IRunnable? top = null;
18+
1719
using AppTestHelper helper = With.A<Window> (40, 10, d)
18-
.Then ((app) =>
19-
{
20-
app?.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit));
21-
Assert.False (app!.TopRunnable!.IsRunning);
22-
});
20+
.Then (app =>
21+
{
22+
top = app!.TopRunnable;
23+
app.Keyboard.RaiseKeyDownEvent (Application.GetDefaultKey (Command.Quit));
24+
});
25+
26+
Assert.True (
27+
SpinWait.SpinUntil (() => top is { IsRunning: false }, TimeSpan.FromSeconds (5)),
28+
"TopRunnable did not stop within timeout.");
2329
}
2430

2531
[Theory]
@@ -317,4 +323,4 @@ public void WithTextField_UpdatesText (string d)
317323

318324
//Assert.Equal ("Hello", textField.Text);
319325
}
320-
}
326+
}

Tests/IntegrationTests/FluentTests/TestContextTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,36 @@ public void With_Starts_Stops_Without_Error (string d)
9999
// No actual assertions are needed — if no exceptions are thrown, it's working
100100
}
101101

102+
[Fact]
103+
[Trait ("Category", "LowLevelDriver")]
104+
public void RunAsync_Cancellation_After_Boot_Stops_Application ()
105+
{
106+
// Copilot
107+
using AppTestHelper helper = With.A<Window> (40, 10, DriverRegistry.Names.ANSI, _out);
108+
helper.CancelRun ();
109+
110+
Assert.True (
111+
SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)),
112+
"AppTestHelper did not finish after cancellation.");
113+
}
114+
115+
[Fact]
116+
[Trait ("Category", "LowLevelDriver")]
117+
public void Then_Exception_HardStops_Without_Hanging ()
118+
{
119+
// Copilot
120+
using AppTestHelper helper = With.A<Window> (40, 10, DriverRegistry.Names.ANSI, _out);
121+
InvalidOperationException expectedException = new ("Expected test failure");
122+
123+
InvalidOperationException exception = Assert.Throws<InvalidOperationException> (
124+
() => helper.Then (_ => throw expectedException));
125+
126+
Assert.Same (expectedException, exception);
127+
Assert.True (
128+
SpinWait.SpinUntil (() => helper.Finished, TimeSpan.FromSeconds (5)),
129+
"AppTestHelper did not finish after action failure.");
130+
}
131+
102132
[Theory]
103133
[MemberData (nameof (GetAllDriverNames))]
104134
public void With_Without_Stop_Still_Cleans_Up (string d)

0 commit comments

Comments
 (0)