Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
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>
2026-10-04 13:18:43 +02:00

257 lines
8.4 KiB
C#

using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// The registry of all instances of a machine (#528, stage 3).
///
/// <para>Two promises stand above everything here, and both follow from E-M6: <b>at startup
/// things are taken over, not started</b>, and <b>on exit nothing is ended.</b>
/// If the manager fails, the instances keep running — it must not be the single point
/// this whole project is built against.</para>
/// </summary>
public sealed class InstanceRegistryTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "reg-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _channel = new();
private readonly List<SupervisionEvent> _events = [];
private readonly InstanceDirectoryManager _directories;
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
StopGrace: TimeSpan.FromMilliseconds(300),
SilentKillGrace: TimeSpan.FromMilliseconds(300),
MaxDrainWait: TimeSpan.FromSeconds(2));
public InstanceRegistryTests()
{
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// The socket only answers if a process is really running - exactly as in
// operation (see FakeChannel.Launcher).
_channel.Launcher = _launcher;
}
private InstanceRegistry Register()
{
var register = new InstanceRegistry(
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
register.Observed += _events.Add;
return register;
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* A leftover test folder does not turn a run red. */ }
}
// ─── What is there ───
[Fact]
public async Task An_empty_instances_directory_gives_an_empty_registry()
{
await using var register = Register();
await register.RefreshAsync();
register.Instances.ShouldBeEmpty();
}
[Fact]
public async Task Created_instances_are_in_the_registry()
{
_directories.CreateInstance("Alpha");
_directories.CreateInstance("Beta");
await using var register = Register();
await register.RefreshAsync();
register.Instances.Select(i => i.Name).ShouldBe(["Alpha", "Beta"]);
register.Instances.ShouldAllBe(i => i.Condition == InstanceCondition.Stopped);
}
/// <summary>
/// A manager that starts everything it finds when coming up would, after a
/// restart of the machine, also start what someone stopped on purpose.
/// </summary>
[Fact]
public async Task Checking_starts_nothing_by_itself()
{
_directories.CreateInstance("Alpha");
await using var register = Register();
await register.RefreshAsync();
_launcher.Starts.ShouldBe(0);
_events.ShouldBeEmpty();
}
[Fact]
public async Task A_cleared_instance_drops_out_of_the_registry()
{
var path = _directories.CreateInstance("Vergaenglich");
await using var register = Register();
await register.RefreshAsync();
register.Instances.Count.ShouldBe(1);
Directory.Delete(path, recursive: true);
await register.RefreshAsync();
register.Instances.ShouldBeEmpty();
}
// ─── Create, start, stop ───
[Fact]
public async Task A_newly_created_instance_is_in_the_registry_at_once_and_not_running()
{
await using var register = Register();
var path = register.Create("Frisch");
Directory.Exists(path).ShouldBeTrue();
File.Exists(Path.Combine(path, "InstanceSettings.json")).ShouldBeTrue();
var instance = register.Instances.ShouldHaveSingleItem();
instance.Condition.ShouldBe(InstanceCondition.Unknown);
_launcher.Starts.ShouldBe(0);
}
[Fact]
public async Task Starting_and_stopping_go_through_the_registry()
{
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var path = register.Create("Alpha");
(await register.StartAsync(path)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// The instance ends upon the request.
var process = _launcher.Last!;
_channel.OnStopRequest = _ => { _channel.Answer = null; process.Exit(0); };
(await register.StopAsync(path)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
}
[Fact]
public async Task An_unknown_directory_is_not_created_silently()
{
await using var register = Register();
await Should.ThrowAsync<KeyNotFoundException>(
() => register.StartAsync(Path.Combine(_root, "gibtsnicht")));
}
// ─── E-M6 ───
[Fact]
public async Task Running_instances_are_adopted_when_checking()
{
string path;
// The first manager creates two and starts only one.
await using (var firstOne = Register())
{
path = firstOne.Create("Alpha");
firstOne.Create("Beta");
// Only Alpha answers. Information that names the same instance for every
// directory would turn green here and in operation mix up two
// instances.
_channel.AnswerFor[Path.GetFullPath(path)] =
new InstanceIdentity("i-1", "Alpha", "0.1.2");
(await firstOne.StartAsync(path)).ShouldBeTrue();
}
_events.Clear();
// Second manager, the same machine.
await using var secondOne = Register();
await secondOne.RefreshAsync();
var alpha = secondOne.Instances.Single(i => i.Name == "Alpha");
alpha.Condition.ShouldBe(InstanceCondition.Running);
alpha.ProcessId.ShouldBe(_launcher.Last!.Id);
// Beta does not answer and stays off - it is not pulled up along with it.
secondOne.Instances.Single(i => i.Name == "Beta").Condition
.ShouldBe(InstanceCondition.Stopped);
// Taking over is not starting.
_launcher.Starts.ShouldBe(1);
_events.ShouldBeEmpty();
}
/// <summary>
/// Whoever closes the manager wants to close the manager — not its instances.
/// </summary>
[Fact]
public async Task Stopping_the_manager_stops_no_instance()
{
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
var register = Register();
var path = register.Create("Alpha");
await register.StartAsync(path);
await register.DisposeAsync();
_channel.StopRequests.ShouldBe(0);
_launcher.Last!.WasKilled.ShouldBeFalse();
_launcher.Last.HasExited.ShouldBeFalse();
// And the state file stays behind - it is the hint for the next one.
InstanceStateStore.Read(path).ShouldNotBeNull();
}
/// <summary>
/// The one exception: a license revocation is not an outage, but a verdict.
/// </summary>
[Fact]
public async Task With_a_reason_affecting_everyone_all_are_paused()
{
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var a = register.Create("Alpha");
await register.StartAsync(a);
var process = _launcher.Last!;
_channel.OnStopRequest = _ => { _channel.Answer = null; process.Exit(0); };
await register.StopAllAsync("Lizenz widerrufen");
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
_channel.StopRequests.ShouldBe(1);
}
// ─── Occurrences ───
[Fact]
public async Task Incidents_of_single_instances_reach_the_registry()
{
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var path = register.Create("Alpha");
await register.StartAsync(path);
_events.Select(e => e.Kind).ShouldBe([InstanceEventKind.Started]);
_events[0].InstanceDirectory.ShouldBe(Path.GetFullPath(path));
}
}