GuiTestContext.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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. InitializeApplication ();
  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. InitializeApplication ();
  101. _booting.Release ();
  102. Toplevel t = topLevelBuilder ();
  103. t.Closed += (s, e) => { Finished = true; };
  104. App?.Run (t); // This will block, but it's on a background thread now
  105. t.Dispose ();
  106. Logging.Trace ("Application.Run completed");
  107. App?.Shutdown ();
  108. _runCancellationTokenSource.Cancel ();
  109. }
  110. catch (OperationCanceledException)
  111. { }
  112. catch (Exception ex)
  113. {
  114. _backgroundException = ex;
  115. _fakeInput.ExternalCancellationTokenSource!.Cancel ();
  116. }
  117. finally
  118. {
  119. CleanupApplication ();
  120. if (_logWriter != null)
  121. {
  122. WriteOutLogs (_logWriter);
  123. }
  124. }
  125. },
  126. _runCancellationTokenSource.Token);
  127. // Wait for booting to complete with a timeout to avoid hangs
  128. if (!_booting.WaitAsync (_timeout).Result)
  129. {
  130. throw new TimeoutException ($"Application failed to start within {_timeout}ms.");
  131. }
  132. ResizeConsole (width, height);
  133. if (_backgroundException is { })
  134. {
  135. throw new ("Application crashed", _backgroundException);
  136. }
  137. }
  138. private void InitializeApplication ()
  139. {
  140. App?.Init (GetDriverName ());
  141. }
  142. /// <summary>
  143. /// Common initialization for both constructors.
  144. /// </summary>
  145. private void CommonInit (int width, int height, TestDriver driverType, TimeSpan? timeout)
  146. {
  147. _timeout = timeout ?? TimeSpan.FromSeconds (10);
  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. IComponentFactory? cf = null;
  175. _output = new FakeOutput ();
  176. // Only set size if explicitly provided (width and height > 0)
  177. if (width > 0 && height > 0)
  178. {
  179. _output.SetSize (width, height);
  180. }
  181. // TODO: As each drivers' IInput/IOutput implementations are made testable (e.g.
  182. // TODO: safely injectable/mocked), we can expand this switch to use them.
  183. switch (driverType)
  184. {
  185. case TestDriver.DotNet:
  186. _sizeMonitor = new (_output);
  187. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  188. break;
  189. case TestDriver.Windows:
  190. _sizeMonitor = new (_output);
  191. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  192. break;
  193. case TestDriver.Unix:
  194. _sizeMonitor = new (_output);
  195. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  196. break;
  197. case TestDriver.Fake:
  198. _sizeMonitor = new (_output);
  199. cf = new FakeComponentFactory (_fakeInput, _output, _sizeMonitor);
  200. break;
  201. }
  202. _applicationImpl = new (cf!);
  203. Logging.Trace ($"Driver: {GetDriverName ()}. Timeout: {_timeout}");
  204. }
  205. private string GetDriverName ()
  206. {
  207. return _driverType switch
  208. {
  209. TestDriver.Windows => "windows",
  210. TestDriver.DotNet => "dotnet",
  211. TestDriver.Unix => "unix",
  212. TestDriver.Fake => "fake",
  213. _ =>
  214. throw new ArgumentOutOfRangeException ()
  215. };
  216. }
  217. /// <summary>
  218. /// Gets whether the application has finished running; aka Stop has been called and the main loop has exited.
  219. /// </summary>
  220. public bool Finished
  221. {
  222. get => _finished;
  223. private set => _finished = value;
  224. }
  225. /// <summary>
  226. /// Performs the supplied <paramref name="doAction"/> immediately.
  227. /// Enables running commands without breaking the Fluent API calls.
  228. /// </summary>
  229. /// <param name="doAction"></param>
  230. /// <returns></returns>
  231. public GuiTestContext Then (Action<IApplication> doAction)
  232. {
  233. try
  234. {
  235. Logging.Trace ($"Invoking action via WaitIteration");
  236. WaitIteration (doAction);
  237. }
  238. catch (Exception ex)
  239. {
  240. _backgroundException = ex;
  241. HardStop ();
  242. throw;
  243. }
  244. return this;
  245. }
  246. /// <summary>
  247. /// Waits until the end of the current iteration of the main loop. Optionally
  248. /// running a given <paramref name="action"/> action on the UI thread at that time.
  249. /// </summary>
  250. /// <param name="action"></param>
  251. /// <returns></returns>
  252. public GuiTestContext WaitIteration (Action<IApplication>? action = null)
  253. {
  254. // If application has already exited don't wait!
  255. if (Finished || _runCancellationTokenSource.Token.IsCancellationRequested || _fakeInput.ExternalCancellationTokenSource!.Token.IsCancellationRequested)
  256. {
  257. Logging.Warning ("WaitIteration called after context was stopped");
  258. return this;
  259. }
  260. if (Thread.CurrentThread.ManagedThreadId == _applicationImpl?.MainThreadId)
  261. {
  262. throw new NotSupportedException ("Cannot WaitIteration during Invoke");
  263. }
  264. Logging.Trace ($"WaitIteration started");
  265. if (action is null)
  266. {
  267. action = (app) => { };
  268. }
  269. CancellationTokenSource ctsActionCompleted = new ();
  270. App?.Invoke (app =>
  271. {
  272. try
  273. {
  274. action (app);
  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 ((app) => { app.Driver!.SetScreenSize (width, height); }); }
  323. public GuiTestContext ScreenShot (string title, TextWriter? writer)
  324. {
  325. //Logging.Trace ($"{title}");
  326. return WaitIteration ((app) =>
  327. {
  328. writer?.WriteLine (title + ":");
  329. var text = app.Driver?.ToString ();
  330. writer?.WriteLine (text);
  331. });
  332. }
  333. public GuiTestContext AnsiScreenShot (string title, TextWriter? writer)
  334. {
  335. //Logging.Trace ($"{title}");
  336. return WaitIteration ((app) =>
  337. {
  338. writer?.WriteLine (title + ":");
  339. var text = app.Driver?.ToAnsi ();
  340. writer?.WriteLine (text);
  341. });
  342. }
  343. /// <summary>
  344. /// Stops the application and waits for the background thread to exit.
  345. /// </summary>
  346. public GuiTestContext Stop ()
  347. {
  348. Logging.Trace ($"Stopping application for driver: {GetDriverName ()}");
  349. if (_runTask is null || _runTask.IsCompleted)
  350. {
  351. // If we didn't run the application, just cleanup
  352. if (!_runApplication && !Finished)
  353. {
  354. try
  355. {
  356. App?.Shutdown ();
  357. }
  358. catch
  359. {
  360. // Ignore errors during shutdown
  361. }
  362. CleanupApplication ();
  363. }
  364. return this;
  365. }
  366. WaitIteration ((app) => { app.RequestStop (); });
  367. // Wait for the application to stop, but give it a 1-second timeout
  368. const int WAIT_TIMEOUT_MS = 1000;
  369. if (!_runTask.Wait (TimeSpan.FromMilliseconds (WAIT_TIMEOUT_MS)))
  370. {
  371. _runCancellationTokenSource.Cancel ();
  372. // No need to manually cancel ExternalCancellationTokenSource
  373. // App is having trouble shutting down, try sending some more shutdown stuff from this thread.
  374. // If this doesn't work there will be test failures as the main loop continues to run during next test.
  375. try
  376. {
  377. App?.RequestStop ();
  378. App?.Shutdown ();
  379. }
  380. catch (Exception ex)
  381. {
  382. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Then shutdown threw {ex}");
  383. }
  384. finally
  385. {
  386. Logging.Critical ($"Application failed to stop in {WAIT_TIMEOUT_MS}. Exception was thrown: {_backgroundException}");
  387. }
  388. }
  389. _runCancellationTokenSource.Cancel ();
  390. if (_backgroundException != null)
  391. {
  392. Logging.Critical ($"Exception occurred: {_backgroundException}");
  393. //throw _ex; // Propagate any exception that happened in the background task
  394. }
  395. return this;
  396. }
  397. /// <summary>
  398. /// Hard stops the application and waits for the background thread to exit.HardStop is used by the source generator for
  399. /// wrapping Xunit assertions.
  400. /// </summary>
  401. public void HardStop (Exception? ex = null)
  402. {
  403. if (ex != null)
  404. {
  405. _backgroundException = ex;
  406. }
  407. Logging.Critical ($"HardStop called with exception: {_backgroundException}");
  408. // With linked tokens, just cancelling ExternalCancellationTokenSource
  409. // will cascade to stop everything
  410. _fakeInput.ExternalCancellationTokenSource?.Cancel ();
  411. WriteOutLogs (_logWriter);
  412. Stop ();
  413. }
  414. /// <summary>
  415. /// Writes all Terminal.Gui engine logs collected so far to the <paramref name="writer"/>
  416. /// </summary>
  417. /// <param name="writer"></param>
  418. /// <returns></returns>
  419. public GuiTestContext WriteOutLogs (TextWriter? writer)
  420. {
  421. if (writer is null)
  422. {
  423. return this;
  424. }
  425. lock (_logsLock)
  426. {
  427. writer.WriteLine (_logsSb!.ToString ());
  428. }
  429. return this; //WaitIteration();
  430. }
  431. internal void Fail (string reason)
  432. {
  433. Logging.Error ($"{reason}");
  434. throw new (reason);
  435. }
  436. private void CleanupApplication ()
  437. {
  438. Logging.Trace ("CleanupApplication");
  439. _fakeInput.ExternalCancellationTokenSource = null;
  440. App?.ResetState (true);
  441. Logging.Logger = _originalLogger!;
  442. Finished = true;
  443. Application.MaximumIterationsPerSecond = Application.DefaultMaximumIterationsPerSecond;
  444. }
  445. /// <summary>
  446. /// Cleanup to avoid state bleed between tests
  447. /// </summary>
  448. public void Dispose ()
  449. {
  450. Logging.Trace ($"Disposing GuiTestContext");
  451. Stop ();
  452. bool shouldThrow = false;
  453. Exception? exToThrow = null;
  454. lock (_cancellationLock) // NEW: Thread-safe check
  455. {
  456. if (_fakeInput.ExternalCancellationTokenSource is { IsCancellationRequested: true })
  457. {
  458. shouldThrow = true;
  459. lock (_backgroundExceptionLock)
  460. {
  461. exToThrow = _backgroundException;
  462. }
  463. }
  464. // ✅ Dispose the linked token source
  465. _fakeInput.ExternalCancellationTokenSource?.Dispose ();
  466. }
  467. _timeoutCts?.Dispose (); // NEW: Dispose timeout CTS
  468. _runCancellationTokenSource?.Dispose ();
  469. _fakeInput.Dispose ();
  470. _output?.Dispose ();
  471. _booting.Dispose ();
  472. if (shouldThrow)
  473. {
  474. throw new ("Application was hard stopped...", exToThrow);
  475. }
  476. }
  477. }