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;
///
/// 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 = InstanceHttpHost.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 = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
var uhr = Stopwatch.StartNew();
await web!.DisposeAsync();
uhr.Stop();
uhr.Elapsed.ShouldBeLessThan(Grenze);
}
///
/// Ein belegter Port kostet die Weboberfläche, nicht die Instanz.
///
/// Vorher gab TryStart hier null zurück, und damit war die
/// gesamte HTTP-Fläche weg. Seit der Socket dazugehört, ist das die falsche
/// Rangfolge: Über ihn wird der Instanzmanager die Instanz erreichen, und ihn wegen
/// eines belegten Ports zu verlieren hieße, die Instanz unerreichbar zu machen, weil
/// eine zweite schon läuft — also genau in dem Fall, für den es den Manager
/// gibt.
///
[Fact]
public async Task Ein_belegter_Port_kostet_nur_die_Weboberflaeche()
{
var port = FreierPort();
using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port);
blockierer.Start();
await using var host = NeuerHost(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.");
// Der Socket traegt trotzdem.
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
(await client.GetAsync("/api/info")).EnsureSuccessStatusCode();
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
}