GuiTestContext.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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<Toplevel> topLevelBuilder, 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. Toplevel t = topLevelBuilder ();
  116. t.Closed += (s, e) => { Finished = true; };
  117. App?.Run (t); // This will block, but it's on a background thread now
  118. t.Dispose ();
  119. Logging.Trace ("Application.Run completed");
  120. App?.Shutdown ();
  121. _runCancellationTokenSource.Cancel ();
  122. }
  123. }
  124. catch (OperationCanceledException)
  125. {
  126. Logging.Trace ("OperationCanceledException");
  127. }
  128. catch (Exception ex)
  129. {
  130. _backgroundException = ex;
  131. _fakeInput.ExternalCancellationTokenSource!.Cancel ();
  132. }
  133. finally
  134. {
  135. CleanupApplication ();
  136. if (_logWriter != null)
  137. {
  138. WriteOutLogs (_logWriter);
  139. }
  140. }
  141. },
  142. _runCancellationTokenSource.Token);
  143. // Wait for booting to complete with a timeout to avoid hangs
  144. if (!_booting.WaitAsync (_timeout).Result)
  145. {
  146. throw new TimeoutException ($"Application failed to start within {_timeout}ms.");
  147. }
  148. ResizeConsole (width, height);
  149. if (_backgroundException is { })
  150. {
  151. throw new ("Application crashed", _backgroundException);
  152. }
  153. }
  154. /// <summary>
  155. /// Common initialization for both constructors.
  156. /// </summary>
  157. private void CommonInit (int width, int height, TestDriver driverType, TimeSpan? timeout)
  158. {
  159. _timeout = timeout ?? TimeSpan.FromSeconds (10);
  160. _originalLogger = Logging.Logger;
  161. _logsSb = new ();
  162. _driverType = driverType;
  163. ILogger logger = LoggerFactory.Create (builder =>
  164. builder.SetMinimumLevel (LogLevel.Trace)
  165. .AddProvider (
  166. new TextWriterLoggerProvider (
  167. new ThreadSafeStringWriter (_logsSb, _logsLock))))
  168. .CreateLogger ("Test Logging");
  169. Logging.Logger = logger;
  170. // ✅ Link _runCancellationTokenSource with a timeout
  171. // This creates a token that responds to EITHER the run cancellation OR timeout
  172. _fakeInput.ExternalCancellationTokenSource =
  173. CancellationTokenSource.CreateLinkedTokenSource (
  174. _runCancellationTokenSource.Token,
  175. new CancellationTokenSource (_timeout).Token);
  176. // Now when InputImpl.Run receives this ExternalCancellationTokenSource,
  177. // it will create ANOTHER linked token internally that combines:
  178. // - Its own runCancellationToken parameter
  179. // - The ExternalCancellationTokenSource (which is already linked)
  180. // This creates a chain: any of these triggers will stop input:
  181. // 1. _runCancellationTokenSource.Cancel() (normal stop)
  182. // 2. Timeout expires (test timeout)
  183. // 3. Direct cancel of ExternalCancellationTokenSource (hard stop/error)
  184. // Remove frame limit
  185. Application.MaximumIterationsPerSecond = ushort.MaxValue;
  186. IComponentFactory? cf = null;
  187. _output = new FakeOutput ();
  188. // Only set size if explicitly provided (width and height > 0)
  189. if (width > 0 && height > 0)
  190. {
  191. _output.SetSize (width, height);
  192. }
  193. // TODO: As each drivers' IInput/IOutput implementations are made testable (e.g.
  194. // TODO: safely injectable/mocked), we can expand this switch to use them.
  195. switch (driverType)
  196. {
  197. case TestDriver.DotNet:
  198. _sizeMonitor = new (_output);
  199. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  200. break;
  201. case TestDriver.Windows:
  202. _sizeMonitor = new (_output);
  203. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  204. break;
  205. case TestDriver.Unix:
  206. _sizeMonitor = new (_output);
  207. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  208. break;
  209. case TestDriver.Fake:
  210. _sizeMonitor = new (_output);
  211. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  212. break;
  213. }
  214. _applicationImpl = new (cf!);
  215. Logging.Trace ($"Driver: {GetDriverName ()}. Timeout: {_timeout}");
  216. }
  217. private string GetDriverName ()
  218. {
  219. return _driverType switch
  220. {
  221. TestDriver.Windows => "windows",
  222. TestDriver.DotNet => "dotnet",
  223. TestDriver.Unix => "unix",
  224. TestDriver.Fake => "fake",
  225. _ =>
  226. throw new ArgumentOutOfRangeException ()
  227. };
  228. }
  229. /// <summary>
  230. /// Gets whether the application has finished running; aka Stop has been called and the main loop has exited.
  231. /// </summary>
  232. public bool Finished
  233. {
  234. get => _finished;
  235. private set => _finished = value;
  236. }
  237. /// <summary>
  238. /// Performs the supplied <paramref name="doAction"/> immediately.
  239. /// Enables running commands without breaking the Fluent API calls.
  240. /// </summary>
  241. /// <param name="doAction"></param>
  242. /// <returns></returns>
  243. public GuiTestContext Then (Action<IApplication> doAction)
  244. {
  245. try
  246. {
  247. Logging.Trace ($"Invoking action via WaitIteration");
  248. WaitIteration (doAction);
  249. }
  250. catch (Exception ex)
  251. {
  252. _backgroundException = ex;
  253. HardStop ();
  254. throw;
  255. }
  256. return this;
  257. }
  258. /// <summary>
  259. /// Waits until the end of the current iteration of the main loop. Optionally
  260. /// running a given <paramref name="action"/> action on the UI thread at that time.
  261. /// </summary>
  262. /// <param name="action"></param>
  263. /// <returns></returns>
  264. public GuiTestContext WaitIteration (Action<IApplication>? action = null)
  265. {
  266. // If application has already exited don't wait!
  267. if (Finished || _runCancellationTokenSource.Token.IsCancellationRequested || _fakeInput.ExternalCancellationTokenSource!.Token.IsCancellationRequested)
  268. {
  269. Logging.Warning ("WaitIteration called after context was stopped");
  270. return this;
  271. }
  272. if (Thread.CurrentThread.ManagedThreadId == _applicationImpl?.MainThreadId)
  273. {
  274. throw new NotSupportedException ("Cannot WaitIteration during Invoke");
  275. }
  276. //Logging.Trace ($"WaitIteration started");
  277. if (action is null)
  278. {
  279. action = (app) => { };
  280. }
  281. CancellationTokenSource ctsActionCompleted = new ();
  282. App?.Invoke (app =>
  283. {
  284. try
  285. {
  286. action (app);
  287. //Logging.Trace ("Action completed");
  288. ctsActionCompleted.Cancel ();
  289. }
  290. catch (Exception e)
  291. {
  292. Logging.Warning ($"Action failed with exception: {e}");
  293. _backgroundException = e;
  294. _fakeInput.ExternalCancellationTokenSource?.Cancel ();
  295. }
  296. });
  297. // Blocks until either the token or the hardStopToken is cancelled.
  298. // With linked tokens, we only need to wait on _runCancellationTokenSource and ctsLocal
  299. // ExternalCancellationTokenSource is redundant because it's linked to _runCancellationTokenSource
  300. WaitHandle.WaitAny (
  301. [
  302. _runCancellationTokenSource.Token.WaitHandle,
  303. ctsActionCompleted.Token.WaitHandle
  304. ]);
  305. // Logging.Trace ($"Return from WaitIteration");
  306. return this;
  307. }
  308. public GuiTestContext WaitUntil (Func<bool> condition)
  309. {
  310. GuiTestContext? c = null;
  311. var sw = Stopwatch.StartNew ();
  312. Logging.Trace ($"WaitUntil started with timeout {_timeout}");
  313. int count = 0;
  314. while (!condition ())
  315. {
  316. if (sw.Elapsed > _timeout)
  317. {
  318. throw new TimeoutException ($"Failed to reach condition within {_timeout}ms");
  319. }
  320. c = WaitIteration ();
  321. count++;
  322. }
  323. Logging.Trace ($"WaitUntil completed after {sw.ElapsedMilliseconds}ms and {count} iterations");
  324. return c ?? this;
  325. }
  326. /// <summary>
  327. /// Returns the last set position of the cursor.
  328. /// </summary>
  329. /// <returns></returns>
  330. public Point GetCursorPosition () { return _output!.GetCursorPosition (); }
  331. /// <summary>
  332. /// Simulates changing the console size e.g. by resizing window in your operating system
  333. /// </summary>
  334. /// <param name="width">new Width for the console.</param>
  335. /// <param name="height">new Height for the console.</param>
  336. /// <returns></returns>
  337. public GuiTestContext ResizeConsole (int width, int height) { return WaitIteration ((app) => { app.Driver!.SetScreenSize (width, height); }); }
  338. public GuiTestContext ScreenShot (string title, TextWriter? writer)
  339. {
  340. //Logging.Trace ($"{title}");
  341. return WaitIteration ((app) =>
  342. {
  343. writer?.WriteLine (title + ":");
  344. var text = app.Driver?.ToString ();
  345. writer?.WriteLine (text);
  346. });
  347. }
  348. public GuiTestContext AnsiScreenShot (string title, TextWriter? writer)
  349. {
  350. //Logging.Trace ($"{title}");
  351. return WaitIteration ((app) =>
  352. {
  353. writer?.WriteLine (title + ":");
  354. var text = app.Driver?.ToAnsi ();
  355. writer?.WriteLine (text);
  356. });
  357. }
  358. /// <summary>
  359. /// Stops the application and waits for the background thread to exit.
  360. /// </summary>
  361. public GuiTestContext Stop ()
  362. {
  363. Logging.Trace ($"Stopping application for driver: {GetDriverName ()}");
  364. if (_runTask is null || _runTask.IsCompleted)
  365. {
  366. // If we didn't run the application, just cleanup
  367. if (!_runApplication && !Finished)
  368. {
  369. try
  370. {
  371. App?.Shutdown ();
  372. }
  373. catch
  374. {
  375. // Ignore errors during shutdown
  376. }
  377. CleanupApplication ();
  378. }
  379. return this;
  380. }
  381. WaitIteration ((app) => { app.RequestStop (); });
  382. // Wait for the application to stop, but give it a 1-second timeout
  383. const int WAIT_TIMEOUT_MS = 1000;
  384. if (!_runTask.Wait (TimeSpan.FromMilliseconds (WAIT_TIMEOUT_MS)))
  385. {
  386. _runCancellationTokenSource.Cancel ();
  387. // No need to manually cancel ExternalCancellationTokenSource
  388. // App is having trouble shutting down, try sending some more shutdown stuff from this thread.
  389. // If this doesn't work there will be test failures as the main loop continues to run during next test.
  390. try
  391. {
  392. App?.RequestStop ();
  393. App?.Shutdown ();
  394. }
  395. catch (Exception ex)
  396. {
  397. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Then shutdown threw {ex}");
  398. }
  399. finally
  400. {
  401. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Exception was thrown: {_backgroundException}");
  402. }
  403. }
  404. _runCancellationTokenSource.Cancel ();
  405. if (_backgroundException != null)
  406. {
  407. Logging.Critical ($"Exception occurred: {_backgroundException}");
  408. //throw _ex; // Propagate any exception that happened in the background task
  409. }
  410. return this;
  411. }
  412. /// <summary>
  413. /// Hard stops the application and waits for the background thread to exit.HardStop is used by the source generator for
  414. /// wrapping Xunit assertions.
  415. /// </summary>
  416. public void HardStop (Exception? ex = null)
  417. {
  418. if (ex != null)
  419. {
  420. _backgroundException = ex;
  421. }
  422. Logging.Critical ($"HardStop called with exception: {_backgroundException}");
  423. // With linked tokens, just cancelling ExternalCancellationTokenSource
  424. // will cascade to stop everything
  425. _fakeInput.ExternalCancellationTokenSource?.Cancel ();
  426. WriteOutLogs (_logWriter);
  427. Stop ();
  428. }
  429. /// <summary>
  430. /// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
  431. /// </summary>
  432. /// <param name="writer"></param>
  433. /// <returns></returns>
  434. public GuiTestContext WriteOutLogs (TextWriter? writer)
  435. {
  436. if (writer is null)
  437. {
  438. return this;
  439. }
  440. lock (_logsLock)
  441. {
  442. writer.WriteLine (_logsSb!.ToString ());
  443. }
  444. return this; //WaitIteration();
  445. }
  446. internal void Fail (string reason)
  447. {
  448. Logging.Error ($"{reason}");
  449. throw new (reason);
  450. }
  451. private void CleanupApplication ()
  452. {
  453. Logging.Trace ("CleanupApplication");
  454. _fakeInput.ExternalCancellationTokenSource = null;
  455. App?.ResetState (true);
  456. Logging.Logger = _originalLogger!;
  457. Finished = true;
  458. Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond;
  459. }
  460. /// <summary>
  461. /// Cleanup to avoid state bleed between tests
  462. /// </summary>
  463. public void Dispose ()
  464. {
  465. Logging.Trace ($"Disposing GuiTestContext");
  466. Stop ();
  467. bool shouldThrow = false;
  468. Exception? exToThrow = null;
  469. lock (_cancellationLock) // NEW: Thread-safe check
  470. {
  471. if (_fakeInput.ExternalCancellationTokenSource is { IsCancellationRequested: true })
  472. {
  473. shouldThrow = true;
  474. lock (_backgroundExceptionLock)
  475. {
  476. exToThrow = _backgroundException;
  477. }
  478. }
  479. // ✅ Dispose the linked token source
  480. _fakeInput.ExternalCancellationTokenSource?.Dispose ();
  481. }
  482. _timeoutCts?.Dispose (); // NEW: Dispose timeout CTS
  483. _runCancellationTokenSource?.Dispose ();
  484. _fakeInput.Dispose ();
  485. _output?.Dispose ();
  486. _booting.Dispose ();
  487. if (shouldThrow)
  488. {
  489. throw new ("Application was hard stopped...", exToThrow);
  490. }
  491. }
  492. }