using System.Diagnostics;
using System.Drawing;
using System.Text;
using Microsoft.Extensions.Logging;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace TerminalGuiFluentTesting;
///
/// Fluent API context for testing a Terminal.Gui application. Create
/// an instance using static class.
///
public partial class GuiTestContext : IDisposable
{
// ===== Threading & Synchronization =====
private readonly CancellationTokenSource _runCancellationTokenSource = new ();
private readonly CancellationTokenSource? _timeoutCts;
private readonly Task? _runTask;
private readonly SemaphoreSlim _booting;
private readonly object _cancellationLock = new ();
private volatile bool _finished;
// ===== Exception Handling =====
private readonly object _backgroundExceptionLock = new ();
private Exception? _backgroundException;
// ===== Driver & Application State =====
private readonly FakeInput _fakeInput = new ();
private IOutput? _output;
private SizeMonitorImpl? _sizeMonitor;
private ApplicationImpl? _applicationImpl;
///
/// The IApplication instance that was created.
///
public IApplication? App => _applicationImpl;
private TestDriver _driverType;
// ===== Application State Preservation (for restoration) =====
private ILogger? _originalLogger;
// ===== Test Configuration =====
private readonly bool _runApplication;
private TimeSpan _timeout;
// ===== Logging =====
private readonly object _logsLock = new ();
private readonly TextWriter? _logWriter;
private StringBuilder? _logsSb;
///
/// Constructor for tests that only need Application.Init without running the main loop.
/// Uses the driver's default screen size instead of forcing a specific size.
///
public GuiTestContext (TestDriver driver, TextWriter? logWriter = null, TimeSpan? timeout = null)
{
_logWriter = logWriter;
_runApplication = false;
_booting = new (0, 1);
_timeoutCts = new CancellationTokenSource (timeout ?? TimeSpan.FromSeconds (10)); // NEW
// Don't force a size - let the driver determine it
CommonInit (0, 0, driver, timeout);
try
{
App?.Init (GetDriverName ());
_booting.Release ();
// After Init, Application.Screen should be set by the driver
if (_applicationImpl?.Screen == Rectangle.Empty)
{
throw new InvalidOperationException (
"Driver bug: Application.Screen is empty after Init. The driver should set the screen size during Init.");
}
}
catch (Exception ex)
{
lock (_backgroundExceptionLock) // NEW: Thread-safe exception handling
{
_backgroundException = ex;
}
if (_logWriter != null)
{
WriteOutLogs (_logWriter);
}
throw new ("Application initialization failed", ex);
}
lock (_backgroundExceptionLock) // NEW: Thread-safe check
{
if (_backgroundException != null)
{
throw new ("Application initialization failed", _backgroundException);
}
}
}
///
/// Constructor for tests that need to run the application with Application.Run.
///
internal GuiTestContext (Func topLevelBuilder, int width, int height, TestDriver driver, TextWriter? logWriter = null, TimeSpan? timeout = null)
{
_logWriter = logWriter;
_runApplication = true;
_booting = new (0, 1);
CommonInit (width, height, driver, timeout);
// Start the application in a background thread
_runTask = Task.Run (
() =>
{
try
{
try
{
App?.Init (GetDriverName ());
}
catch (Exception e)
{
Logging.Error(e.Message);
_runCancellationTokenSource.Cancel ();
}
finally
{
_booting.Release ();
}
if (App is { Initialized: true })
{
Toplevel t = topLevelBuilder ();
t.Closed += (s, e) => { Finished = true; };
App?.Run (t); // This will block, but it's on a background thread now
t.Dispose ();
Logging.Trace ("Application.Run completed");
App?.Shutdown ();
_runCancellationTokenSource.Cancel ();
}
}
catch (OperationCanceledException)
{
Logging.Trace ("OperationCanceledException");
}
catch (Exception ex)
{
_backgroundException = ex;
_fakeInput.ExternalCancellationTokenSource!.Cancel ();
}
finally
{
CleanupApplication ();
if (_logWriter != null)
{
WriteOutLogs (_logWriter);
}
}
},
_runCancellationTokenSource.Token);
// Wait for booting to complete with a timeout to avoid hangs
if (!_booting.WaitAsync (_timeout).Result)
{
throw new TimeoutException ($"Application failed to start within {_timeout}ms.");
}
ResizeConsole (width, height);
if (_backgroundException is { })
{
throw new ("Application crashed", _backgroundException);
}
}
///
/// Common initialization for both constructors.
///
private void CommonInit (int width, int height, TestDriver driverType, TimeSpan? timeout)
{
_timeout = timeout ?? TimeSpan.FromSeconds (10);
_originalLogger = Logging.Logger;
_logsSb = new ();
_driverType = driverType;
ILogger logger = LoggerFactory.Create (builder =>
builder.SetMinimumLevel (LogLevel.Trace)
.AddProvider (
new TextWriterLoggerProvider (
new ThreadSafeStringWriter (_logsSb, _logsLock))))
.CreateLogger ("Test Logging");
Logging.Logger = logger;
// ✅ Link _runCancellationTokenSource with a timeout
// This creates a token that responds to EITHER the run cancellation OR timeout
_fakeInput.ExternalCancellationTokenSource =
CancellationTokenSource.CreateLinkedTokenSource (
_runCancellationTokenSource.Token,
new CancellationTokenSource (_timeout).Token);
// Now when InputImpl.Run receives this ExternalCancellationTokenSource,
// it will create ANOTHER linked token internally that combines:
// - Its own runCancellationToken parameter
// - The ExternalCancellationTokenSource (which is already linked)
// This creates a chain: any of these triggers will stop input:
// 1. _runCancellationTokenSource.Cancel() (normal stop)
// 2. Timeout expires (test timeout)
// 3. Direct cancel of ExternalCancellationTokenSource (hard stop/error)
// Remove frame limit
Application.MaximumIterationsPerSecond = ushort.MaxValue;
IComponentFactory? cf = null;
_output = new FakeOutput ();
// Only set size if explicitly provided (width and height > 0)
if (width > 0 && height > 0)
{
_output.SetSize (width, height);
}
// TODO: As each drivers' IInput/IOutput implementations are made testable (e.g.
// TODO: safely injectable/mocked), we can expand this switch to use them.
switch (driverType)
{
case TestDriver.DotNet:
_sizeMonitor = new (_output);
cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
break;
case TestDriver.Windows:
_sizeMonitor = new (_output);
cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
break;
case TestDriver.Unix:
_sizeMonitor = new (_output);
cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
break;
case TestDriver.Fake:
_sizeMonitor = new (_output);
cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
break;
}
_applicationImpl = new (cf!);
Logging.Trace ($"Driver: {GetDriverName ()}. Timeout: {_timeout}");
}
private string GetDriverName ()
{
return _driverType switch
{
TestDriver.Windows => "windows",
TestDriver.DotNet => "dotnet",
TestDriver.Unix => "unix",
TestDriver.Fake => "fake",
_ =>
throw new ArgumentOutOfRangeException ()
};
}
///
/// Gets whether the application has finished running; aka Stop has been called and the main loop has exited.
///
public bool Finished
{
get => _finished;
private set => _finished = value;
}
///
/// Performs the supplied immediately.
/// Enables running commands without breaking the Fluent API calls.
///
///
///
public GuiTestContext Then (Action doAction)
{
try
{
Logging.Trace ($"Invoking action via WaitIteration");
WaitIteration (doAction);
}
catch (Exception ex)
{
_backgroundException = ex;
HardStop ();
throw;
}
return this;
}
///
/// Waits until the end of the current iteration of the main loop. Optionally
/// running a given action on the UI thread at that time.
///
///
///
public GuiTestContext WaitIteration (Action? action = null)
{
// If application has already exited don't wait!
if (Finished || _runCancellationTokenSource.Token.IsCancellationRequested || _fakeInput.ExternalCancellationTokenSource!.Token.IsCancellationRequested)
{
Logging.Warning ("WaitIteration called after context was stopped");
return this;
}
if (Thread.CurrentThread.ManagedThreadId == _applicationImpl?.MainThreadId)
{
throw new NotSupportedException ("Cannot WaitIteration during Invoke");
}
//Logging.Trace ($"WaitIteration started");
if (action is null)
{
action = (app) => { };
}
CancellationTokenSource ctsActionCompleted = new ();
App?.Invoke (app =>
{
try
{
action (app);
//Logging.Trace ("Action completed");
ctsActionCompleted.Cancel ();
}
catch (Exception e)
{
Logging.Warning ($"Action failed with exception: {e}");
_backgroundException = e;
_fakeInput.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
]);
// Logging.Trace ($"Return from WaitIteration");
return this;
}
public GuiTestContext WaitUntil (Func condition)
{
GuiTestContext? c = null;
var sw = Stopwatch.StartNew ();
Logging.Trace ($"WaitUntil started with timeout {_timeout}");
int count = 0;
while (!condition ())
{
if (sw.Elapsed > _timeout)
{
throw new TimeoutException ($"Failed to reach condition within {_timeout}ms");
}
c = WaitIteration ();
count++;
}
Logging.Trace ($"WaitUntil completed after {sw.ElapsedMilliseconds}ms and {count} iterations");
return c ?? this;
}
///
/// Returns the last set position of the cursor.
///
///
public Point GetCursorPosition () { return _output!.GetCursorPosition (); }
///
/// Simulates changing the console size e.g. by resizing window in your operating system
///
/// new Width for the console.
/// new Height for the console.
///
public GuiTestContext ResizeConsole (int width, int height) { return WaitIteration ((app) => { app.Driver!.SetScreenSize (width, height); }); }
public GuiTestContext ScreenShot (string title, TextWriter? writer)
{
//Logging.Trace ($"{title}");
return WaitIteration ((app) =>
{
writer?.WriteLine (title + ":");
var text = app.Driver?.ToString ();
writer?.WriteLine (text);
});
}
public GuiTestContext AnsiScreenShot (string title, TextWriter? writer)
{
//Logging.Trace ($"{title}");
return WaitIteration ((app) =>
{
writer?.WriteLine (title + ":");
var text = app.Driver?.ToAnsi ();
writer?.WriteLine (text);
});
}
///
/// Stops the application and waits for the background thread to exit.
///
public GuiTestContext Stop ()
{
Logging.Trace ($"Stopping application for driver: {GetDriverName ()}");
if (_runTask is null || _runTask.IsCompleted)
{
// If we didn't run the application, just cleanup
if (!_runApplication && !Finished)
{
try
{
App?.Shutdown ();
}
catch
{
// Ignore errors during shutdown
}
CleanupApplication ();
}
return this;
}
WaitIteration ((app) => { app.RequestStop (); });
// Wait for the application to stop, but give it a 1-second timeout
const int WAIT_TIMEOUT_MS = 1000;
if (!_runTask.Wait (TimeSpan.FromMilliseconds (WAIT_TIMEOUT_MS)))
{
_runCancellationTokenSource.Cancel ();
// No need to manually cancel ExternalCancellationTokenSource
// App is having trouble shutting down, try sending some more shutdown stuff from this thread.
// If this doesn't work there will be test failures as the main loop continues to run during next test.
try
{
App?.RequestStop ();
App?.Shutdown ();
}
catch (Exception ex)
{
Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Then shutdown threw {ex}");
}
finally
{
Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Exception was thrown: {_backgroundException}");
}
}
_runCancellationTokenSource.Cancel ();
if (_backgroundException != null)
{
Logging.Critical ($"Exception occurred: {_backgroundException}");
//throw _ex; // Propagate any exception that happened in the background task
}
return this;
}
///
/// Hard stops the application and waits for the background thread to exit.HardStop is used by the source generator for
/// wrapping Xunit assertions.
///
public void HardStop (Exception? ex = null)
{
if (ex != null)
{
_backgroundException = ex;
}
Logging.Critical ($"HardStop called with exception: {_backgroundException}");
// With linked tokens, just cancelling ExternalCancellationTokenSource
// will cascade to stop everything
_fakeInput.ExternalCancellationTokenSource?.Cancel ();
WriteOutLogs (_logWriter);
Stop ();
}
///
/// Writes all Terminal.Gui engine logs collected so far to the
///
///
///
public GuiTestContext WriteOutLogs (TextWriter? writer)
{
if (writer is null)
{
return this;
}
lock (_logsLock)
{
writer.WriteLine (_logsSb!.ToString ());
}
return this; //WaitIteration();
}
internal void Fail (string reason)
{
Logging.Error ($"{reason}");
throw new (reason);
}
private void CleanupApplication ()
{
Logging.Trace ("CleanupApplication");
_fakeInput.ExternalCancellationTokenSource = null;
App?.ResetState (true);
Logging.Logger = _originalLogger!;
Finished = true;
Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond;
}
///
/// Cleanup to avoid state bleed between tests
///
public void Dispose ()
{
Logging.Trace ($"Disposing GuiTestContext");
Stop ();
bool shouldThrow = false;
Exception? exToThrow = null;
lock (_cancellationLock) // NEW: Thread-safe check
{
if (_fakeInput.ExternalCancellationTokenSource is { IsCancellationRequested: true })
{
shouldThrow = true;
lock (_backgroundExceptionLock)
{
exToThrow = _backgroundException;
}
}
// ✅ Dispose the linked token source
_fakeInput.ExternalCancellationTokenSource?.Dispose ();
}
_timeoutCts?.Dispose (); // NEW: Dispose timeout CTS
_runCancellationTokenSource?.Dispose ();
_fakeInput.Dispose ();
_output?.Dispose ();
_booting.Dispose ();
if (shouldThrow)
{
throw new ("Application was hard stopped...", exToThrow);
}
}
}