Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
6.1 KiB
C#
163 lines
6.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The web UI must be able to shut down quickly — even with open event streams.
|
|
///
|
|
/// <para><b>The occasion.</b> An event stream never ends by itself. During an orderly
|
|
/// shutdown the browser does not hang up, so <c>RequestAborted</c> does not fire,
|
|
/// and Kestrel waits patiently for the request to finish. Only after the deadline
|
|
/// of <c>StopAsync</c> 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.</para>
|
|
///
|
|
/// <para>The streams now additionally hang on <c>ApplicationStopping</c>. This test
|
|
/// records the promise so it does not get silently lost in the next rework.</para>
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A taken port costs the web UI, not the instance.
|
|
///
|
|
/// <para>Previously <c>TryStart</c> returned <c>null</c> 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.</para>
|
|
/// </summary>
|
|
[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. */ }
|
|
}
|
|
}
|