using ClawdDotNet.Core.Instances; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// The registry of all instances of a machine (#528, stage 3). /// /// Two promises stand above everything here, and both follow from E-M6: at startup /// things are taken over, not started, and on exit nothing is ended. /// If the manager fails, the instances keep running — it must not be the single point /// this whole project is built against. /// 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 _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); } /// /// A manager that starts everything it finds when coming up would, after a /// restart of the machine, also start what someone stopped on purpose. /// [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( () => 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(); } /// /// Whoever closes the manager wants to close the manager — not its instances. /// [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(); } /// /// The one exception: a license revocation is not an outage, but a verdict. /// [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)); } }