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 class GuiTestContext : IDisposable { private readonly CancellationTokenSource _cts = new (); private readonly CancellationTokenSource _hardStop; private readonly Task _runTask; private Exception? _ex; private readonly FakeOutput _output = new (); private readonly FakeWindowsInput _winInput; private readonly FakeNetInput _netInput; private View? _lastView; private readonly object _logsLock = new (); private readonly StringBuilder _logsSb; private readonly TestDriver _driver; private bool _finished; private readonly FakeSizeMonitor _fakeSizeMonitor; private readonly TimeSpan _timeout; internal GuiTestContext (Func topLevelBuilder, int width, int height, TestDriver driver, TextWriter? logWriter = null, TimeSpan? timeout = null) { _timeout = timeout ?? TimeSpan.FromSeconds (30); _hardStop = new (_timeout); // Remove frame limit Application.MaximumIterationsPerSecond = ushort.MaxValue; IApplication origApp = ApplicationImpl.Instance; ILogger? origLogger = Logging.Logger; _logsSb = new (); _driver = driver; _netInput = new (_cts.Token); _winInput = new (_cts.Token); _output.Size = new (width, height); _fakeSizeMonitor = new (_output, _output.LastBuffer!); IComponentFactory cf = driver == TestDriver.DotNet ? new FakeNetComponentFactory (_netInput, _output, _fakeSizeMonitor) : (IComponentFactory)new FakeWindowsComponentFactory (_winInput, _output, _fakeSizeMonitor); var impl = new ApplicationImpl (cf); var booting = new SemaphoreSlim (0, 1); // Start the application in a background thread _runTask = Task.Run ( () => { try { ApplicationImpl.ChangeInstance (impl); ILogger logger = LoggerFactory.Create ( builder => builder.SetMinimumLevel (LogLevel.Trace) .AddProvider ( new TextWriterLoggerProvider ( new ThreadSafeStringWriter (_logsSb, _logsLock)))) .CreateLogger ("Test Logging"); Logging.Logger = logger; impl.Init (null, GetDriverName ()); booting.Release (); Toplevel t = topLevelBuilder (); t.Closed += (s, e) => { _finished = true; }; Application.Run (t); // This will block, but it's on a background thread now t.Dispose (); Application.Shutdown (); _cts.Cancel (); } catch (OperationCanceledException) { } catch (Exception ex) { _ex = ex; if (logWriter != null) { WriteOutLogs (logWriter); } _hardStop.Cancel (); } finally { ApplicationImpl.ChangeInstance (origApp); Logging.Logger = origLogger; _finished = true; Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond; } }, _cts.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 the allotted time."); } ResizeConsole (width, height); if (_ex != null) { throw new ("Application crashed", _ex); } } private string GetDriverName () { return _driver switch { TestDriver.Windows => "windows", TestDriver.DotNet => "dotnet", _ => throw new ArgumentOutOfRangeException () }; } /// /// Stops the application and waits for the background thread to exit. /// public GuiTestContext Stop () { if (_runTask.IsCompleted) { return this; } WaitIteration (() => { Application.RequestStop (); }); // Wait for the application to stop, but give it a 1-second timeout if (!_runTask.Wait (TimeSpan.FromMilliseconds (1000))) { _cts.Cancel (); // Timeout occurred, force the task to stop _hardStop.Cancel (); // App is having trouble shutting down, try sending some more shutdown stuff from this thread. // If this doesn't work there will be test cascade failures as the main loop continues to run during next test. try { Application.RequestStop (); Application.Shutdown (); } catch (Exception) { throw new TimeoutException ("Application failed to stop within the allotted time.", _ex); } throw new TimeoutException ("Application failed to stop within the allotted time.", _ex); } _cts.Cancel (); if (_ex != null) { 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. /// public void HardStop (Exception? ex = null) { if (ex != null) { _ex = ex; } _hardStop.Cancel (); Stop (); } /// /// Cleanup to avoid state bleed between tests /// public void Dispose () { Stop (); if (_hardStop.IsCancellationRequested) { throw new ( "Application was hard stopped, typically this means it timed out or did not shutdown gracefully. Ensure you call Stop in your test", _ex); } _hardStop.Cancel (); } /// /// Adds the given to the current top level view /// and performs layout. /// /// /// public GuiTestContext Add (View v) { WaitIteration ( () => { Toplevel top = Application.Top ?? throw new ("Top was null so could not add view"); top.Add (v); top.Layout (); _lastView = v; }); return this; } /// /// 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 ( () => { Application.Driver!.SetScreenSize(width, height); }); } public GuiTestContext ScreenShot (string title, TextWriter writer) { return WaitIteration ( () => { writer.WriteLine (title + ":"); var text = Application.ToString (); writer.WriteLine (text); }); } /// /// Writes all Terminal.Gui engine logs collected so far to the /// /// /// public GuiTestContext WriteOutLogs (TextWriter writer) { lock (_logsLock) { writer.WriteLine (_logsSb.ToString ()); } return this; //WaitIteration(); } /// /// 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? a = null) { // If application has already exited don't wait! if (_finished || _cts.Token.IsCancellationRequested || _hardStop.Token.IsCancellationRequested) { return this; } if (Thread.CurrentThread.ManagedThreadId == Application.MainThreadId) { throw new NotSupportedException ("Cannot WaitIteration during Invoke"); } a ??= () => { }; var ctsLocal = new CancellationTokenSource (); Application.Invoke ( () => { try { a (); ctsLocal.Cancel (); } catch (Exception e) { _ex = e; _hardStop.Cancel (); } }); // Blocks until either the token or the hardStopToken is cancelled. WaitHandle.WaitAny ( new [] { _cts.Token.WaitHandle, _hardStop.Token.WaitHandle, ctsLocal.Token.WaitHandle }); return this; } /// /// Performs the supplied immediately. /// Enables running commands without breaking the Fluent API calls. /// /// /// public GuiTestContext Then (Action doAction) { try { WaitIteration (doAction); } catch (Exception ex) { _ex = ex; HardStop (); throw; } return this; } /// /// Simulates a right click at the given screen coordinates on the current driver. /// This is a raw input event that goes through entire processing pipeline as though /// user had pressed the mouse button physically. /// /// 0 indexed screen coordinates /// 0 indexed screen coordinates /// public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed, screenX, screenY); } /// /// Simulates a left click at the given screen coordinates on the current driver. /// This is a raw input event that goes through entire processing pipeline as though /// user had pressed the mouse button physically. /// /// 0 indexed screen coordinates /// 0 indexed screen coordinates /// public GuiTestContext LeftClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); } public GuiTestContext LeftClick (Func evaluator) where T : View { return Click (WindowsConsole.ButtonState.Button1Pressed, evaluator); } private GuiTestContext Click (WindowsConsole.ButtonState btn, Func evaluator) where T : View { T v; var screen = Point.Empty; GuiTestContext ctx = WaitIteration ( () => { v = Find (evaluator); screen = v.ViewportToScreen (new Point (0, 0)); }); Click (btn, screen.X, screen.Y); return ctx; } private GuiTestContext Click (WindowsConsole.ButtonState btn, int screenX, int screenY) { switch (_driver) { case TestDriver.Windows: _winInput.InputBuffer!.Enqueue ( new () { EventType = WindowsConsole.EventType.Mouse, MouseEvent = new () { ButtonState = btn, MousePosition = new ((short)screenX, (short)screenY) } }); _winInput.InputBuffer.Enqueue ( new () { EventType = WindowsConsole.EventType.Mouse, MouseEvent = new () { ButtonState = WindowsConsole.ButtonState.NoButtonPressed, MousePosition = new ((short)screenX, (short)screenY) } }); return WaitUntil (() => _winInput.InputBuffer.IsEmpty); case TestDriver.DotNet: int netButton = btn switch { WindowsConsole.ButtonState.Button1Pressed => 0, WindowsConsole.ButtonState.Button2Pressed => 1, WindowsConsole.ButtonState.Button3Pressed => 2, WindowsConsole.ButtonState.RightmostButtonPressed => 2, _ => throw new ArgumentOutOfRangeException (nameof (btn)) }; foreach (ConsoleKeyInfo k in NetSequences.Click (netButton, screenX, screenY)) { SendNetKey (k, false); } return WaitIteration (); default: throw new ArgumentOutOfRangeException (); } } private GuiTestContext WaitUntil (Func condition) { GuiTestContext? c = null; var sw = Stopwatch.StartNew (); while (!condition ()) { if (sw.Elapsed > _timeout) { throw new TimeoutException ("Failed to reach condition within the time limit"); } c = WaitIteration (); } return c ?? this; } public GuiTestContext Down () { switch (_driver) { case TestDriver.Windows: SendWindowsKey (ConsoleKeyMapping.VK.DOWN); break; case TestDriver.DotNet: foreach (ConsoleKeyInfo k in NetSequences.Down) { SendNetKey (k); } break; default: throw new ArgumentOutOfRangeException (); } return WaitIteration (); } /// /// Simulates the Right cursor key /// /// /// public GuiTestContext Right () { switch (_driver) { case TestDriver.Windows: SendWindowsKey (ConsoleKeyMapping.VK.RIGHT); break; case TestDriver.DotNet: foreach (ConsoleKeyInfo k in NetSequences.Right) { SendNetKey (k); } WaitIteration (); break; default: throw new ArgumentOutOfRangeException (); } return WaitIteration (); } /// /// Simulates the Left cursor key /// /// /// public GuiTestContext Left () { switch (_driver) { case TestDriver.Windows: SendWindowsKey (ConsoleKeyMapping.VK.LEFT); break; case TestDriver.DotNet: foreach (ConsoleKeyInfo k in NetSequences.Left) { SendNetKey (k); } break; default: throw new ArgumentOutOfRangeException (); } return WaitIteration (); } /// /// Simulates the up cursor key /// /// /// public GuiTestContext Up () { switch (_driver) { case TestDriver.Windows: SendWindowsKey (ConsoleKeyMapping.VK.UP); break; case TestDriver.DotNet: foreach (ConsoleKeyInfo k in NetSequences.Up) { SendNetKey (k); } break; default: throw new ArgumentOutOfRangeException (); } return WaitIteration (); } /// /// Simulates pressing the Return/Enter (newline) key. /// /// /// public GuiTestContext Enter () { switch (_driver) { case TestDriver.Windows: SendWindowsKey ( new WindowsConsole.KeyEventRecord { UnicodeChar = '\r', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, wRepeatCount = 1, wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN, wVirtualScanCode = 28 }); break; case TestDriver.DotNet: SendNetKey (new ('\r', ConsoleKey.Enter, false, false, false)); break; default: throw new ArgumentOutOfRangeException (); } return WaitIteration (); } /// /// Simulates pressing the Esc (Escape) key. /// /// /// public GuiTestContext Escape () { switch (_driver) { case TestDriver.Windows: SendWindowsKey ( new WindowsConsole.KeyEventRecord { UnicodeChar = '\u001b', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, wRepeatCount = 1, wVirtualKeyCode = ConsoleKeyMapping.VK.ESCAPE, wVirtualScanCode = 1 }); break; case TestDriver.DotNet: // Note that this accurately describes how Esc comes in. Typically, ConsoleKey is None // even though you would think it would be Escape - it isn't SendNetKey (new ('\u001b', ConsoleKey.None, false, false, false)); break; default: throw new ArgumentOutOfRangeException (); } return this; } /// /// Simulates pressing the Tab key. /// /// /// public GuiTestContext Tab () { switch (_driver) { case TestDriver.Windows: SendWindowsKey ( new WindowsConsole.KeyEventRecord { UnicodeChar = '\t', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, wRepeatCount = 1, wVirtualKeyCode = 0, wVirtualScanCode = 0 }); break; case TestDriver.DotNet: // Note that this accurately describes how Tab comes in. Typically, ConsoleKey is None // even though you would think it would be Tab - it isn't SendNetKey (new ('\t', ConsoleKey.None, false, false, false)); break; default: throw new ArgumentOutOfRangeException (); } return this; } /// /// Registers a right click handler on the added view (or root view) that /// will open the supplied . /// /// /// public GuiTestContext WithContextMenu (PopoverMenu? contextMenu) { LastView.MouseEvent += (s, e) => { if (e.Flags.HasFlag (MouseFlags.Button3Clicked)) { // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. Application.Popover?.Register (contextMenu); contextMenu?.MakeVisible (e.ScreenPosition); } }; return this; } /// /// The last view added (e.g. with ) or the root/current top. /// public View LastView => _lastView ?? Application.Top ?? throw new ("Could not determine which view to add to"); /// /// Send a full windows OS key including both down and up. /// /// private void SendWindowsKey (WindowsConsole.KeyEventRecord fullKey) { WindowsConsole.KeyEventRecord down = fullKey; WindowsConsole.KeyEventRecord up = fullKey; // because struct this is new copy down.bKeyDown = true; up.bKeyDown = false; _winInput.InputBuffer!.Enqueue ( new () { EventType = WindowsConsole.EventType.Key, KeyEvent = down }); _winInput.InputBuffer.Enqueue ( new () { EventType = WindowsConsole.EventType.Key, KeyEvent = up }); WaitIteration (); } private void SendNetKey (ConsoleKeyInfo consoleKeyInfo, bool wait = true) { _netInput.InputBuffer!.Enqueue (consoleKeyInfo); if (wait) { WaitUntil (() => _netInput.InputBuffer.IsEmpty); } } /// /// Sends a special key e.g. cursor key that does not map to a specific character /// /// private void SendWindowsKey (ConsoleKeyMapping.VK specialKey) { _winInput.InputBuffer!.Enqueue ( new () { EventType = WindowsConsole.EventType.Key, KeyEvent = new () { bKeyDown = true, wRepeatCount = 0, wVirtualKeyCode = specialKey, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); _winInput.InputBuffer.Enqueue ( new () { EventType = WindowsConsole.EventType.Key, KeyEvent = new () { bKeyDown = false, wRepeatCount = 0, wVirtualKeyCode = specialKey, wVirtualScanCode = 0, UnicodeChar = '\0', dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed } }); WaitIteration (); } /// /// Sends a key to the application. This goes directly to Application and does not go through /// a driver. /// /// /// public GuiTestContext RaiseKeyDownEvent (Key key) { WaitIteration (() => Application.RaiseKeyDownEvent (key)); return this; //WaitIteration(); } /// /// Sets the input focus to the given . /// Throws if focus did not change due to system /// constraints e.g. /// is /// /// /// /// public GuiTestContext Focus (View toFocus) { toFocus.FocusDeepest (NavigationDirection.Forward, TabBehavior.TabStop); if (!toFocus.HasFocus) { throw new ArgumentException ("Failed to set focus, FocusDeepest did not result in HasFocus becoming true. Ensure view is added and focusable"); } return WaitIteration (); } /// /// Tabs through the UI until a View matching the /// is found (of Type T) or all views are looped through (back to the beginning) /// in which case triggers hard stop and Exception /// /// /// Delegate that returns true if the passed View is the one /// you are trying to focus. Leave to focus the first view of type /// /// /// /// public GuiTestContext Focus (Func? evaluator = null) where T : View { evaluator ??= _ => true; Toplevel? t = Application.Top; HashSet seen = new (); if (t == null) { Fail ("Application.Top was null when trying to set focus"); return this; } do { View? next = t.MostFocused; // Is view found? if (next is T v && evaluator (v)) { return this; } // No, try tab to the next (or first) Tab (); WaitIteration (); next = t.MostFocused; if (next is null) { Fail ( "Failed to tab to a view which matched the Type and evaluator constraints of the test because MostFocused became or was always null" + DescribeSeenViews (seen)); return this; } // Track the views we have seen // We have looped around to the start again if it was already there if (!seen.Add (next)) { Fail ( "Failed to tab to a view which matched the Type and evaluator constraints of the test before looping back to the original View" + DescribeSeenViews (seen)); return this; } } while (true); } private string DescribeSeenViews (HashSet seen) { return Environment.NewLine + string.Join (Environment.NewLine, seen); } private T Find (Func evaluator) where T : View { Toplevel? t = Application.Top; if (t == null) { Fail ("Application.Top was null when attempting to find view"); } T? f = FindRecursive (t!, evaluator); if (f == null) { Fail ("Failed to tab to a view which matched the Type and evaluator constraints in any SubViews of top"); } return f!; } private T? FindRecursive (View current, Func evaluator) where T : View { foreach (View subview in current.SubViews) { if (subview is T match && evaluator (match)) { return match; } // Recursive call T? result = FindRecursive (subview, evaluator); if (result != null) { return result; } } return null; } private void Fail (string reason) { Stop (); throw new (reason); } public GuiTestContext Send (Key key) { return WaitIteration ( () => { if (Application.Driver is IConsoleDriverFacade facade) { facade.InputProcessor.OnKeyDown (key); facade.InputProcessor.OnKeyUp (key); } else { Fail ("Expected Application.Driver to be IConsoleDriverFacade"); } }); } /// /// Returns the last set position of the cursor. /// /// public Point GetCursorPosition () { return _output.CursorPosition; } } internal class FakeWindowsComponentFactory (FakeWindowsInput winInput, FakeOutput output, FakeSizeMonitor fakeSizeMonitor) : WindowsComponentFactory { /// public override IConsoleInput CreateInput () { return winInput; } /// public override IConsoleOutput CreateOutput () { return output; } /// public override IConsoleSizeMonitor CreateConsoleSizeMonitor (IConsoleOutput consoleOutput, IOutputBuffer outputBuffer) { outputBuffer.SetSize (consoleOutput.GetSize ().Width, consoleOutput.GetSize ().Height); return fakeSizeMonitor; } } internal class FakeNetComponentFactory (FakeNetInput netInput, FakeOutput output, FakeSizeMonitor fakeSizeMonitor) : NetComponentFactory { /// public override IConsoleInput CreateInput () { return netInput; } /// public override IConsoleOutput CreateOutput () { return output; } /// public override IConsoleSizeMonitor CreateConsoleSizeMonitor (IConsoleOutput consoleOutput, IOutputBuffer outputBuffer) { outputBuffer.SetSize (consoleOutput.GetSize ().Width, consoleOutput.GetSize ().Height); return fakeSizeMonitor; } }