GuiTestContext.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. using System.Collections.Concurrent;
  2. using System.Diagnostics;
  3. using System.Drawing;
  4. using System.Text;
  5. using Microsoft.Extensions.Logging;
  6. #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
  7. namespace TerminalGuiFluentTesting;
  8. /// <summary>
  9. /// Fluent API context for testing a Terminal.Gui application. Create
  10. /// an instance using <see cref="With"/> static class.
  11. /// </summary>
  12. public partial class GuiTestContext : IDisposable
  13. {
  14. // ===== Threading & Synchronization =====
  15. private readonly CancellationTokenSource _runCancellationTokenSource = new ();
  16. private readonly CancellationTokenSource? _timeoutCts;
  17. private readonly Task? _runTask;
  18. private readonly SemaphoreSlim _booting;
  19. private readonly object _cancellationLock = new ();
  20. private volatile bool _finished;
  21. // ===== Exception Handling =====
  22. private readonly object _backgroundExceptionLock = new ();
  23. private Exception? _backgroundException;
  24. // ===== Driver & Application State =====
  25. private readonly FakeInput _fakeInput = new ();
  26. private IOutput? _output;
  27. private SizeMonitorImpl? _sizeMonitor;
  28. private ApplicationImpl? _applicationImpl;
  29. private TestDriver _driverType;
  30. // ===== Application State Preservation (for restoration) =====
  31. private IApplication? _originalApplicationInstance;
  32. private ILogger? _originalLogger;
  33. // ===== Test Configuration =====
  34. private readonly bool _runApplication;
  35. private TimeSpan _timeout;
  36. // ===== Logging =====
  37. private readonly object _logsLock = new ();
  38. private readonly TextWriter? _logWriter;
  39. private StringBuilder? _logsSb;
  40. /// <summary>
  41. /// Constructor for tests that only need Application.Init without running the main loop.
  42. /// Uses the driver's default screen size instead of forcing a specific size.
  43. /// </summary>
  44. public GuiTestContext (TestDriver driver, TextWriter? logWriter = null, TimeSpan? timeout = null)
  45. {
  46. _logWriter = logWriter;
  47. _runApplication = false;
  48. _booting = new (0, 1);
  49. _timeoutCts = new CancellationTokenSource (timeout ?? TimeSpan.FromSeconds (10)); // NEW
  50. // Don't force a size - let the driver determine it
  51. CommonInit (0, 0, driver, timeout);
  52. try
  53. {
  54. InitializeApplication ();
  55. _booting.Release ();
  56. // After Init, Application.Screen should be set by the driver
  57. if (Application.Screen == Rectangle.Empty)
  58. {
  59. throw new InvalidOperationException (
  60. "Driver bug: Application.Screen is empty after Init. The driver should set the screen size during Init.");
  61. }
  62. }
  63. catch (Exception ex)
  64. {
  65. lock (_backgroundExceptionLock) // NEW: Thread-safe exception handling
  66. {
  67. _backgroundException = ex;
  68. }
  69. if (_logWriter != null)
  70. {
  71. WriteOutLogs (_logWriter);
  72. }
  73. throw new ("Application initialization failed", ex);
  74. }
  75. lock (_backgroundExceptionLock) // NEW: Thread-safe check
  76. {
  77. if (_backgroundException != null)
  78. {
  79. throw new ("Application initialization failed", _backgroundException);
  80. }
  81. }
  82. }
  83. /// <summary>
  84. /// Constructor for tests that need to run the application with Application.Run.
  85. /// </summary>
  86. internal GuiTestContext (Func<Toplevel> topLevelBuilder, int width, int height, TestDriver driver, TextWriter? logWriter = null, TimeSpan? timeout = null)
  87. {
  88. _logWriter = logWriter;
  89. _runApplication = true;
  90. _booting = new (0, 1);
  91. CommonInit (width, height, driver, timeout);
  92. // Start the application in a background thread
  93. _runTask = Task.Run (
  94. () =>
  95. {
  96. try
  97. {
  98. InitializeApplication ();
  99. _booting.Release ();
  100. Toplevel t = topLevelBuilder ();
  101. t.Closed += (s, e) => { Finished = true; };
  102. Application.Run (t); // This will block, but it's on a background thread now
  103. t.Dispose ();
  104. Logging.Trace ("Application.Run completed");
  105. Application.Shutdown ();
  106. _runCancellationTokenSource.Cancel ();
  107. }
  108. catch (OperationCanceledException)
  109. { }
  110. catch (Exception ex)
  111. {
  112. _backgroundException = ex;
  113. _fakeInput.ExternalCancellationTokenSource!.Cancel ();
  114. }
  115. finally
  116. {
  117. CleanupApplication ();
  118. if (_logWriter != null)
  119. {
  120. WriteOutLogs (_logWriter);
  121. }
  122. }
  123. },
  124. _runCancellationTokenSource.Token);
  125. // Wait for booting to complete with a timeout to avoid hangs
  126. if (!_booting.WaitAsync (_timeout).Result)
  127. {
  128. throw new TimeoutException ($"Application failed to start within {_timeout}ms.");
  129. }
  130. ResizeConsole (width, height);
  131. if (_backgroundException is { })
  132. {
  133. throw new ("Application crashed", _backgroundException);
  134. }
  135. }
  136. private void InitializeApplication ()
  137. {
  138. ApplicationImpl.ChangeInstance (_applicationImpl);
  139. _applicationImpl?.Init (null, GetDriverName ());
  140. }
  141. /// <summary>
  142. /// Common initialization for both constructors.
  143. /// </summary>
  144. private void CommonInit (int width, int height, TestDriver driverType, TimeSpan? timeout)
  145. {
  146. _timeout = timeout ?? TimeSpan.FromSeconds (10);
  147. _originalApplicationInstance = ApplicationImpl.Instance;
  148. _originalLogger = Logging.Logger;
  149. _logsSb = new ();
  150. _driverType = driverType;
  151. ILogger logger = LoggerFactory.Create (builder =>
  152. builder.SetMinimumLevel (LogLevel.Trace)
  153. .AddProvider (
  154. new TextWriterLoggerProvider (
  155. new ThreadSafeStringWriter (_logsSb, _logsLock))))
  156. .CreateLogger ("Test Logging");
  157. Logging.Logger = logger;
  158. // ✅ Link _runCancellationTokenSource with a timeout
  159. // This creates a token that responds to EITHER the run cancellation OR timeout
  160. _fakeInput.ExternalCancellationTokenSource =
  161. CancellationTokenSource.CreateLinkedTokenSource (
  162. _runCancellationTokenSource.Token,
  163. new CancellationTokenSource (_timeout).Token);
  164. // Now when InputImpl.Run receives this ExternalCancellationTokenSource,
  165. // it will create ANOTHER linked token internally that combines:
  166. // - Its own runCancellationToken parameter
  167. // - The ExternalCancellationTokenSource (which is already linked)
  168. // This creates a chain: any of these triggers will stop input:
  169. // 1. _runCancellationTokenSource.Cancel() (normal stop)
  170. // 2. Timeout expires (test timeout)
  171. // 3. Direct cancel of ExternalCancellationTokenSource (hard stop/error)
  172. // Remove frame limit
  173. Application.MaximumIterationsPerSecond = ushort.MaxValue;
  174. //// Only set size if explicitly provided (width and height > 0)
  175. //if (width > 0 && height > 0)
  176. //{
  177. // _output.SetSize (width, height);
  178. //}
  179. IComponentFactory? cf = null;
  180. // TODO: As each drivers' IInput/IOutput implementations are made testable (e.g.
  181. // TODO: safely injectable/mocked), we can expand this switch to use them.
  182. switch (driverType)
  183. {
  184. case TestDriver.DotNet:
  185. _output = new FakeOutput ();
  186. _sizeMonitor = new (_output);
  187. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  188. break;
  189. case TestDriver.Windows:
  190. _output = new FakeOutput ();
  191. _sizeMonitor = new (_output);
  192. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  193. break;
  194. case TestDriver.Unix:
  195. _output = new FakeOutput ();
  196. _sizeMonitor = new (_output);
  197. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  198. break;
  199. case TestDriver.Fake:
  200. _output = new FakeOutput ();
  201. _sizeMonitor = new (_output);
  202. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  203. break;
  204. }
  205. _applicationImpl = new (cf!);
  206. Logging.Trace ($"Driver: {GetDriverName ()}. Timeout: {_timeout}");
  207. }
  208. private string GetDriverName ()
  209. {
  210. return _driverType switch
  211. {
  212. TestDriver.Windows => "windows",
  213. TestDriver.DotNet => "dotnet",
  214. TestDriver.Unix => "unix",
  215. TestDriver.Fake => "fake",
  216. _ =>
  217. throw new ArgumentOutOfRangeException ()
  218. };
  219. }
  220. /// <summary>
  221. /// Gets whether the application has finished running; aka Stop has been called and the main loop has exited.
  222. /// </summary>
  223. public bool Finished
  224. {
  225. get => _finished;
  226. private set => _finished = value;
  227. }
  228. /// <summary>
  229. /// Performs the supplied <paramref name="doAction"/> immediately.
  230. /// Enables running commands without breaking the Fluent API calls.
  231. /// </summary>
  232. /// <param name="doAction"></param>
  233. /// <returns></returns>
  234. public GuiTestContext Then (Action doAction)
  235. {
  236. try
  237. {
  238. Logging.Trace ($"Invoking action via WaitIteration");
  239. WaitIteration (doAction);
  240. }
  241. catch (Exception ex)
  242. {
  243. _backgroundException = ex;
  244. HardStop ();
  245. throw;
  246. }
  247. return this;
  248. }
  249. /// <summary>
  250. /// Waits until the end of the current iteration of the main loop. Optionally
  251. /// running a given <paramref name="action"/> action on the UI thread at that time.
  252. /// </summary>
  253. /// <param name="action"></param>
  254. /// <returns></returns>
  255. public GuiTestContext WaitIteration (Action? action = null)
  256. {
  257. // If application has already exited don't wait!
  258. if (Finished || _runCancellationTokenSource.Token.IsCancellationRequested || _fakeInput.ExternalCancellationTokenSource!.Token.IsCancellationRequested)
  259. {
  260. Logging.Warning ("WaitIteration called after context was stopped");
  261. return this;
  262. }
  263. if (Thread.CurrentThread.ManagedThreadId == Application.MainThreadId)
  264. {
  265. throw new NotSupportedException ("Cannot WaitIteration during Invoke");
  266. }
  267. Logging.Trace ($"WaitIteration started");
  268. action ??= () => { };
  269. CancellationTokenSource ctsActionCompleted = new ();
  270. Application.Invoke (() =>
  271. {
  272. try
  273. {
  274. action ();
  275. //Logging.Trace ("Action completed");
  276. ctsActionCompleted.Cancel ();
  277. }
  278. catch (Exception e)
  279. {
  280. Logging.Warning ($"Action failed with exception: {e}");
  281. _backgroundException = e;
  282. _fakeInput.ExternalCancellationTokenSource?.Cancel ();
  283. }
  284. });
  285. // Blocks until either the token or the hardStopToken is cancelled.
  286. // With linked tokens, we only need to wait on _runCancellationTokenSource and ctsLocal
  287. // ExternalCancellationTokenSource is redundant because it's linked to _runCancellationTokenSource
  288. WaitHandle.WaitAny (
  289. [
  290. _runCancellationTokenSource.Token.WaitHandle,
  291. ctsActionCompleted.Token.WaitHandle
  292. ]);
  293. // Logging.Trace ($"Return from WaitIteration");
  294. return this;
  295. }
  296. public GuiTestContext WaitUntil (Func<bool> condition)
  297. {
  298. GuiTestContext? c = null;
  299. var sw = Stopwatch.StartNew ();
  300. //Logging.Trace ($"WaitUntil started with timeout {_timeout}");
  301. while (!condition ())
  302. {
  303. if (sw.Elapsed > _timeout)
  304. {
  305. throw new TimeoutException ($"Failed to reach condition within {_timeout}ms");
  306. }
  307. c = WaitIteration ();
  308. }
  309. return c ?? this;
  310. }
  311. /// <summary>
  312. /// Returns the last set position of the cursor.
  313. /// </summary>
  314. /// <returns></returns>
  315. public Point GetCursorPosition () { return _output!.GetCursorPosition (); }
  316. /// <summary>
  317. /// Simulates changing the console size e.g. by resizing window in your operating system
  318. /// </summary>
  319. /// <param name="width">new Width for the console.</param>
  320. /// <param name="height">new Height for the console.</param>
  321. /// <returns></returns>
  322. public GuiTestContext ResizeConsole (int width, int height) { return WaitIteration (() => { Application.Driver!.SetScreenSize (width, height); }); }
  323. public GuiTestContext ScreenShot (string title, TextWriter? writer)
  324. {
  325. //Logging.Trace ($"{title}");
  326. return WaitIteration (() =>
  327. {
  328. writer?.WriteLine (title + ":");
  329. var text = Application.ToString ();
  330. writer?.WriteLine (text);
  331. });
  332. }
  333. /// <summary>
  334. /// Stops the application and waits for the background thread to exit.
  335. /// </summary>
  336. public GuiTestContext Stop ()
  337. {
  338. Logging.Trace ($"Stopping application for driver: {GetDriverName ()}");
  339. if (_runTask is null || _runTask.IsCompleted)
  340. {
  341. // If we didn't run the application, just cleanup
  342. if (!_runApplication && !Finished)
  343. {
  344. try
  345. {
  346. Application.Shutdown ();
  347. }
  348. catch
  349. {
  350. // Ignore errors during shutdown
  351. }
  352. CleanupApplication ();
  353. }
  354. return this;
  355. }
  356. WaitIteration (() => { Application.RequestStop (); });
  357. // Wait for the application to stop, but give it a 1-second timeout
  358. const int WAIT_TIMEOUT_MS = 1000;
  359. if (!_runTask.Wait (TimeSpan.FromMilliseconds (WAIT_TIMEOUT_MS)))
  360. {
  361. _runCancellationTokenSource.Cancel ();
  362. // No need to manually cancel ExternalCancellationTokenSource
  363. // App is having trouble shutting down, try sending some more shutdown stuff from this thread.
  364. // If this doesn't work there will be test failures as the main loop continues to run during next test.
  365. try
  366. {
  367. Application.RequestStop ();
  368. Application.Shutdown ();
  369. }
  370. catch (Exception ex)
  371. {
  372. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Then shutdown threw {ex}");
  373. }
  374. finally
  375. {
  376. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Exception was thrown: {_backgroundException}");
  377. }
  378. }
  379. _runCancellationTokenSource.Cancel ();
  380. if (_backgroundException != null)
  381. {
  382. Logging.Critical ($"Exception occurred: {_backgroundException}");
  383. //throw _ex; // Propagate any exception that happened in the background task
  384. }
  385. return this;
  386. }
  387. /// <summary>
  388. /// Hard stops the application and waits for the background thread to exit.HardStop is used by the source generator for
  389. /// wrapping Xunit assertions.
  390. /// </summary>
  391. public void HardStop (Exception? ex = null)
  392. {
  393. if (ex != null)
  394. {
  395. _backgroundException = ex;
  396. }
  397. Logging.Critical ($"HardStop called with exception: {_backgroundException}");
  398. // With linked tokens, just cancelling ExternalCancellationTokenSource
  399. // will cascade to stop everything
  400. _fakeInput.ExternalCancellationTokenSource?.Cancel ();
  401. WriteOutLogs (_logWriter);
  402. Stop ();
  403. }
  404. /// <summary>
  405. /// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
  406. /// </summary>
  407. /// <param name="writer"></param>
  408. /// <returns></returns>
  409. public GuiTestContext WriteOutLogs (TextWriter? writer)
  410. {
  411. if (writer is null)
  412. {
  413. return this;
  414. }
  415. lock (_logsLock)
  416. {
  417. writer.WriteLine (_logsSb!.ToString ());
  418. }
  419. return this; //WaitIteration();
  420. }
  421. internal void Fail (string reason)
  422. {
  423. Logging.Error ($"{reason}");
  424. throw new (reason);
  425. }
  426. private void CleanupApplication ()
  427. {
  428. Logging.Trace ("CleanupApplication");
  429. _fakeInput.ExternalCancellationTokenSource = null;
  430. Application.ResetState (true);
  431. ApplicationImpl.ChangeInstance (_originalApplicationInstance);
  432. Logging.Logger = _originalLogger;
  433. Finished = true;
  434. Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond;
  435. }
  436. /// <summary>
  437. /// Cleanup to avoid state bleed between tests
  438. /// </summary>
  439. public void Dispose ()
  440. {
  441. Logging.Trace ($"Disposing GuiTestContext");
  442. Stop ();
  443. bool shouldThrow = false;
  444. Exception? exToThrow = null;
  445. lock (_cancellationLock) // NEW: Thread-safe check
  446. {
  447. if (_fakeInput.ExternalCancellationTokenSource is { IsCancellationRequested: true })
  448. {
  449. shouldThrow = true;
  450. lock (_backgroundExceptionLock)
  451. {
  452. exToThrow = _backgroundException;
  453. }
  454. }
  455. // ✅ Dispose the linked token source
  456. _fakeInput.ExternalCancellationTokenSource?.Dispose ();
  457. }
  458. _timeoutCts?.Dispose (); // NEW: Dispose timeout CTS
  459. _runCancellationTokenSource?.Dispose ();
  460. _fakeInput.Dispose ();
  461. _output?.Dispose ();
  462. _booting.Dispose ();
  463. if (shouldThrow)
  464. {
  465. throw new ("Application was hard stopped...", exToThrow);
  466. }
  467. }
  468. }