| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- 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;
- /// <summary>
- /// Fluent API context for testing a Terminal.Gui application. Create
- /// an instance using <see cref="With"/> static class.
- /// </summary>
- 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;
- /// <summary>
- /// The IApplication instance that was created.
- /// </summary>
- 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;
- /// <summary>
- /// 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.
- /// </summary>
- 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);
- }
- }
- }
- /// <summary>
- /// Constructor for tests that need to run the application with Application.Run.
- /// </summary>
- internal GuiTestContext (Func<IRunnable> runnableBuilder, 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 })
- {
- IRunnable runnable = runnableBuilder ();
- runnable.IsRunningChanged += (s, e) =>
- {
- if (!e.Value)
- {
- Finished = true;
- }
- };
- App?.Run (runnable); // This will block, but it's on a background thread now
- if (runnable is View runnableView)
- {
- runnableView.Dispose ();
- }
- Logging.Trace ("Application.Run completed");
- App?.Dispose ();
- _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);
- }
- }
- /// <summary>
- /// Common initialization for both constructors.
- /// </summary>
- private void CommonInit (int width, int height, TestDriver driverType, TimeSpan? timeout)
- {
- _timeout = timeout ?? TimeSpan.FromSeconds (30);
- _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 ()
- };
- }
- /// <summary>
- /// Gets whether the application has finished running; aka Stop has been called and the main loop has exited.
- /// </summary>
- public bool Finished
- {
- get => _finished;
- private set => _finished = value;
- }
- /// <summary>
- /// Performs the supplied <paramref name="doAction"/> immediately.
- /// Enables running commands without breaking the Fluent API calls.
- /// </summary>
- /// <param name="doAction"></param>
- /// <returns></returns>
- public GuiTestContext Then (Action<IApplication> doAction)
- {
- try
- {
- Logging.Trace ($"Invoking action via WaitIteration");
- WaitIteration (doAction);
- }
- catch (Exception ex)
- {
- _backgroundException = ex;
- HardStop ();
- throw;
- }
- return this;
- }
- /// <summary>
- /// Waits until the end of the current iteration of the main loop. Optionally
- /// running a given <paramref name="action"/> action on the UI thread at that time.
- /// </summary>
- /// <param name="action"></param>
- /// <returns></returns>
- public GuiTestContext WaitIteration (Action<IApplication>? 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<bool> 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;
- }
- /// <summary>
- /// Returns the last set position of the cursor.
- /// </summary>
- /// <returns></returns>
- public Point GetCursorPosition () { return _output!.GetCursorPosition (); }
- /// <summary>
- /// Simulates changing the console size e.g. by resizing window in your operating system
- /// </summary>
- /// <param name="width">new Width for the console.</param>
- /// <param name="height">new Height for the console.</param>
- /// <returns></returns>
- 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);
- });
- }
- /// <summary>
- /// Stops the application and waits for the background thread to exit.
- /// </summary>
- 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?.Dispose ();
- }
- 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?.Dispose ();
- }
- 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;
- }
- /// <summary>
- /// Hard stops the application and waits for the background thread to exit.HardStop is used by the source generator for
- /// wrapping Xunit assertions.
- /// </summary>
- 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 ();
- }
- /// <summary>
- /// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
- /// </summary>
- /// <param name="writer"></param>
- /// <returns></returns>
- 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}");
- WriteOutLogs (_logWriter);
- throw new (reason);
- }
- private void CleanupApplication ()
- {
- Logging.Trace ("CleanupApplication");
- _fakeInput.ExternalCancellationTokenSource = null;
- App?.ResetState (true);
- Logging.Logger = _originalLogger!;
- Finished = true;
- Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond;
- }
- /// <summary>
- /// Cleanup to avoid state bleed between tests
- /// </summary>
- 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);
- }
- }
- }
|