using System.Diagnostics; using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Web.Tests; /// /// The web UI must be able to shut down quickly — even with open event streams. /// /// The occasion. An event stream never ends by itself. During an orderly /// shutdown the browser does not hang up, so RequestAborted does not fire, /// and Kestrel waits patiently for the request to finish. Only after the deadline /// of StopAsync was it cut off: five seconds in which the application stood still. /// It became visible when switching instances — between "switch" and the restart there was this /// pause every time, as soon as a browser window was open anywhere. /// /// The streams now additionally hang on ApplicationStopping. This test /// records the promise so it does not get silently lost in the next rework. /// 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"; /// /// Generously sized: measured against the deadline of StopAsync (five /// seconds). If the test stays below two, the promise is kept without a /// slow build server turning it red. /// private static readonly TimeSpan Limit = TimeSpan.FromSeconds(2); private AppHost NewHost(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() }; } /// /// A free port from the operating system. A fixed number would let two test runs /// fail side by side — and those are exactly the errors you search for the longest. /// private static int FreePort() { 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 Shutdown_does_not_wait_for_an_open_event_stream() { var port = FreePort(); await using var host = NewHost(port); var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); // Really open the stream and wait for the first line: only then does // Kestrel hold the request, and only then does the test check what it wants to check. using var answer = await client.GetAsync( $"http://127.0.0.1:{port}/events?token={Token}", HttpCompletionOption.ResponseHeadersRead); answer.EnsureSuccessStatusCode(); await using var stream = await answer.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); (await reader.ReadLineAsync()).ShouldBe(": bereit"); var clock = Stopwatch.StartNew(); await web!.DisposeAsync(); clock.Stop(); clock.Elapsed.ShouldBeLessThan(Limit, $"Das Beenden dauerte {clock.Elapsed.TotalSeconds:0.0}s — der Ereignisstrom " + "hat das Herunterfahren nicht bemerkt."); } [Fact] public async Task Shutdown_is_quick_even_without_an_open_connection() { var port = FreePort(); await using var host = NewHost(port); var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); var clock = Stopwatch.StartNew(); await web!.DisposeAsync(); clock.Stop(); clock.Elapsed.ShouldBeLessThan(Limit); } /// /// A taken port costs the web UI, not the instance. /// /// Previously TryStart returned null here, and with that the /// entire HTTP surface was gone. Since the socket belongs to it, that is the wrong /// order of priority: the instance manager will reach the instance through it, and losing it because /// of a taken port would mean making the instance unreachable because /// a second one is already running — i.e. exactly in the case the manager /// exists for. /// [Fact] public async Task An_occupied_port_only_costs_the_web_UI() { var port = FreePort(); using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port); blockierer.Start(); await using var host = NewHost(port); await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); web.ShouldNotBeNull(); web!.Url.ShouldBeNull("Die Weboberfläche konnte nicht binden — das gehört gesagt."); // The socket holds anyway. using var client = InstanceEndpoint.CreateClient(host.InstancePath); (await client.GetAsync("/api/info")).EnsureSuccessStatusCode(); } public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* A leftover test folder does not turn a run red. */ } } }