GuiTestContext.cs 21 KB

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