using System.Diagnostics; using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Web.Tests; /// /// Die Weboberfläche muss sich zügig beenden lassen — auch mit offenen Ereignisströmen. /// /// Der Anlass. Ein Ereignisstrom endet nie von selbst. Beim geordneten /// Herunterfahren legt der Browser nicht auf, also feuert RequestAborted nicht, /// und Kestrel wartet geduldig darauf, dass die Anfrage fertig wird. Erst nach der Frist /// von StopAsync wurde abgeschnitten: fünf Sekunden, in denen die Anwendung stand. /// Sichtbar wurde es beim Instanzwechsel — zwischen „Wechseln" und dem Neustart lag jedes /// Mal diese Pause, sobald irgendwo ein Browserfenster offen war. /// /// Die Ströme hängen jetzt zusätzlich an ApplicationStopping. Dieser Test /// hält die Zusage fest, damit sie nicht beim nächsten Umbau still verlorengeht. /// public sealed class ShutdownTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "web-" + Guid.NewGuid().ToString("N")[..8]); private const string Token = "ein-hinreichend-langes-testtoken"; /// /// Grosszuegig bemessen: Gemessen wird gegen die Frist von StopAsync (fuenf /// Sekunden). Bleibt der Test unter zwei, ist die Zusage eingehalten, ohne dass ein /// langsamer Bauserver ihn rot faerbt. /// private static readonly TimeSpan Grenze = TimeSpan.FromSeconds(2); private AppHost NeuerHost(int port) { var instancesDir = Path.Combine(_root, "Instances"); var instancePath = Path.Combine(instancesDir, "Test"); Directory.CreateDirectory(instancePath); return new AppHost { Settings = new SettingsManager(_root), Directories = new InstanceDirectoryManager(instancesDir), Instance = new InstanceConfig { InstanceName = "Test", WebUiEnabled = true, WebUiBinding = "loopback", WebUiToken = Token, WebServerPort = port }, InstancePath = instancePath, LogDirectory = Path.Combine(_root, "Logs"), LoggerFactory = NullLoggerFactory.Instance, Tools = new ToolRegistry() }; } /// /// Ein freier Port vom Betriebssystem. Eine feste Zahl liesse zwei Testlaeufe /// nebeneinander scheitern — und genau solche Fehler sucht man am laengsten. /// private static int FreierPort() { using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return port; } [Fact] public async Task Beenden_wartet_nicht_auf_einen_offenen_Ereignisstrom() { var port = FreierPort(); await using var host = NeuerHost(port); var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); // Den Strom wirklich oeffnen und die erste Zeile abwarten: Erst dann haelt // Kestrel die Anfrage, und erst dann prueft der Test das, was er pruefen will. using var antwort = await client.GetAsync( $"http://127.0.0.1:{port}/events?token={Token}", HttpCompletionOption.ResponseHeadersRead); antwort.EnsureSuccessStatusCode(); await using var strom = await antwort.Content.ReadAsStreamAsync(); using var leser = new StreamReader(strom); (await leser.ReadLineAsync()).ShouldBe(": bereit"); var uhr = Stopwatch.StartNew(); await web!.DisposeAsync(); uhr.Stop(); uhr.Elapsed.ShouldBeLessThan(Grenze, $"Das Beenden dauerte {uhr.Elapsed.TotalSeconds:0.0}s — der Ereignisstrom " + "hat das Herunterfahren nicht bemerkt."); } [Fact] public async Task Beenden_geht_auch_ohne_offene_Verbindung_zuegig() { var port = FreierPort(); await using var host = NeuerHost(port); var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); var uhr = Stopwatch.StartNew(); await web!.DisposeAsync(); uhr.Stop(); uhr.Elapsed.ShouldBeLessThan(Grenze); } [Fact] public void Ein_belegter_Port_haelt_die_Anwendung_nicht_auf() { var port = FreierPort(); using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port); blockierer.Start(); var host = NeuerHost(port); // Kein Wurf, sondern null: Ein Fenster, das nicht aufgeht, weil ein Webserver // keine Adresse bekommt, waere genau dann im Weg, wenn man es braucht. WebUiHost.TryStart(host, NullLoggerFactory.Instance).ShouldBeNull(); } public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } }