using ClawdDotNet.Core.Instances; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// Zählt mit, wem ein Token besorgt wurde. internal sealed class FakeTokenSource : IInstanceTokenSource { public List Fuer { get; } = []; public bool Erfolg { get; set; } = true; public Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default) { Fuer.Add(Path.GetFullPath(instanceDirectory)); return Task.FromResult(Erfolg); } } /// /// Der Instanzmanager als Ganzes (#528, Stufe 3). /// /// Die Lizenz wird hier ohne Lizenzserver geprüft, und das ist Absicht: Die /// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —, /// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau /// dieser Weg wird hier gegangen, ausgelöst über /// . /// public sealed class ManagerCoreTests : IAsyncDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "core-" + Guid.NewGuid().ToString("N")[..8]); private readonly FakeLauncher _launcher = new(); private readonly FakeChannel _kanal = new(); private readonly FakeTokenSource _tokens = new(); private readonly InstanceDirectoryManager _verzeichnisse; 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 ManagerCoreTests() { Directory.CreateDirectory(_root); _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im // Betrieb (siehe FakeChannel.Launcher). _kanal.Launcher = _launcher; } private ManagerCore Manager() { var register = new InstanceRegistry( _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); return new ManagerCore( new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance); } public async ValueTask DisposeAsync() { await Task.CompletedTask; try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } /// /// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich /// gestartet und nicht bloß übernommen wurde. /// /// Der Unterschied ist keine Feinheit. Wer die Antwort schon vor dem /// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der /// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test /// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal /// passiert. /// private async Task StarteUndPruefe(ManagerCore manager, string pfad, string name) { var vorher = _launcher.Starts; _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2"); var ergebnis = await manager.StartInstanceAsync(pfad); _launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen."); return ergebnis; } // ─── Hochfahren ─── [Fact] public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager() { // Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre, // bevor ueberhaupt jemand einen Schluessel eintragen kann. await using var manager = Manager(); (await manager.StartAsync()).ShouldBeTrue(); manager.LicenseHolds.ShouldBeTrue(); } [Fact] public async Task Beim_Hochfahren_wird_nichts_gestartet() { _verzeichnisse.CreateInstance("Alpha"); await using var manager = Manager(); await manager.StartAsync(); manager.Instances.Instances.Count.ShouldBe(1); _launcher.Starts.ShouldBe(0); } // ─── Token ─── [Fact] public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token() { // Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei // zweifelsfrei niemandem. await using var manager = Manager(); var pfad = await manager.CreateInstanceAsync("Frisch"); _tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]); } [Fact] public async Task Vor_dem_Start_wird_das_Token_nachgeholt() { // Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das // Deploymentcenter beim Anlegen nicht erreichbar war. var pfad = _verzeichnisse.CreateInstance("Alt"); await using var manager = Manager(); await manager.StartAsync(); (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None); _tokens.Fuer.ShouldContain(Path.GetFullPath(pfad)); } [Fact] public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht() { // Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still, // wenn man es braucht. _tokens.Erfolg = false; var pfad = _verzeichnisse.CreateInstance("Alt"); await using var manager = Manager(); await manager.StartAsync(); (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None); } // ─── Das Urteil ─── /// /// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält. /// /// Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und /// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein /// Netzproblem sämtliche Installationen gleichzeitig mit. /// [Fact] public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an() { var pfad = _verzeichnisse.CreateInstance("Alpha"); await using var manager = Manager(); await manager.StartAsync(); await StarteUndPruefe(manager, pfad, "Alpha"); manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); // Die Instanz endet auf die Bitte hin. _launcher.Last!.Exit(0); _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller"); manager.LicenseHolds.ShouldBeFalse(); manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); _kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen."); } [Fact] public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr() { var pfad = _verzeichnisse.CreateInstance("Alpha"); await using var manager = Manager(); await manager.StartAsync(); await manager.HandleLicenseRevokedAsync("Widerrufen"); (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense); _launcher.Starts.ShouldBe(0); _tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht."); } // ─── Wenn der Start scheitert ─── [Fact] public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet() { var pfad = _verzeichnisse.CreateInstance("Stumm"); await using var manager = Manager(); await manager.StartAsync(); // Der Prozess laeuft, die Instanz meldet sich nie. (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart); } } /// /// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5). /// /// Vorher hiess „Instanz wechseln": Der laufende Prozess fährt sich herunter /// und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangsläufig /// die Instanz, aus der heraus er es tat — und genau dabei hat sich am 2026-09-08 die /// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn. /// public sealed class InstanceFocusTests : IAsyncDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]); private readonly FakeLauncher _launcher = new(); private readonly FakeChannel _kanal = new(); private readonly InstanceDirectoryManager _verzeichnisse; 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 InstanceFocusTests() { Directory.CreateDirectory(_root); _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im // Betrieb (siehe FakeChannel.Launcher). _kanal.Launcher = _launcher; } private InstanceRegistry Register() => new( _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); public async ValueTask DisposeAsync() { await Task.CompletedTask; try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } [Fact] public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt() { await using var register = Register(); var pfad = register.Create("Alpha"); _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); await register.StartAsync(pfad); (await register.FocusAsync(pfad)).ShouldBeTrue(); _kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]); // Und vor allem: Es wurde nichts beendet und nichts neu gestartet. _launcher.Starts.ShouldBe(1); _kanal.StopRequests.ShouldBe(0); _launcher.Last!.HasExited.ShouldBeFalse(); } [Fact] public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster() { await using var register = Register(); var pfad = register.Create("Alpha"); (await register.FocusAsync(pfad)).ShouldBeFalse(); // Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht. _kanal.FocusRequests.ShouldBeEmpty(); } /// /// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg /// zu melden. /// [Fact] public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat() { await using var register = Register(); var pfad = register.Create("Alpha"); _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); await register.StartAsync(pfad); _kanal.HasWindow = false; (await register.FocusAsync(pfad)).ShouldBeFalse(); _kanal.FocusRequests.ShouldHaveSingleItem(); } }