using ClawdDotNet.Core.Instances; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// manager.json und der Protokollauszug — die beiden Stellen, an denen der Manager /// mit dem Dateisystem spricht (#528, Stufe 3). /// public sealed class ManagerSettingsTests : IDisposable { private readonly string _dir = Path.Combine( Path.GetTempPath(), "clawd-tests", "mgr-" + Guid.NewGuid().ToString("N")[..8]); public ManagerSettingsTests() => Directory.CreateDirectory(_dir); public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } [Fact] public void Eine_fehlende_Datei_wird_mit_Vorgaben_angelegt() { var store = new ManagerSettingsStore(_dir); var settings = store.Load(); File.Exists(store.SettingsPath).ShouldBeTrue("Der Ort soll sichtbar werden."); settings.WebUiPort.ShouldBe(7000); settings.UpdateChannel.ShouldBe("prod"); } /// /// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur /// Laufzeit aber schon, sonst könnte niemand damit arbeiten. /// [Fact] public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei() { var store = new ManagerSettingsStore(_dir); store.Load(); store.Settings.DeploymentcenterMasterToken = "dc_master_geheim_1234567890"; store.Settings.LicenseKey = "LIZ-1234-5678"; store.Save(); File.ReadAllText(store.SettingsPath).ShouldNotContain("dc_master_geheim_1234567890"); File.ReadAllText(store.SettingsPath).ShouldNotContain("LIZ-1234-5678"); var erneut = new ManagerSettingsStore(_dir).Load(); erneut.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890"); erneut.LicenseKey.ShouldBe("LIZ-1234-5678"); } /// /// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark, /// und sie darf trotzdem gelten. /// /// Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete /// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag. /// [Fact] public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch() { var pfad = Path.Combine(_dir, "manager.json"); // Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach // dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad. var inhalt = """ { "instanceExecutable": "J:\\Programme\\ClawdDotNet.exe", "webUiPort": 7009, "deploymentcenterUrl": "" } """; File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM."); var store = new ManagerSettingsStore(_dir); var settings = store.Load(); store.LoadProblem.ShouldBeNull(); settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe"); settings.WebUiPort.ShouldBe(7009); settings.DeploymentcenterUrl.ShouldBe(""); } /// /// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar /// keine Instanz mehr, und das ist der schlechtere Zustand. /// [Fact] public void Eine_kaputte_Datei_verhindert_den_Start_nicht() { File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON"); var store = new ManagerSettingsStore(_dir); store.Load().WebUiPort.ShouldBe(7000); // Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit // Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist. store.LoadProblem.ShouldNotBeNullOrWhiteSpace(); } /// /// Unsinnige Zahlen können in einer von Hand bearbeitbaren Datei stehen. Ein /// Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null Fehlversuchen ein /// Alarm bei jedem Aussetzer. /// [Fact] public void Unsinnige_Aufsichtswerte_werden_eingefangen() { var settings = new ManagerSettings { ProbeIntervalSeconds = 0, StartTimeoutSeconds = -5, SilentChecksBeforeAlarm = 0, StopGraceSeconds = 99999 }; var options = settings.ToSupervisionOptions(); options.ProbeInterval.ShouldBeGreaterThan(TimeSpan.Zero); options.StartTimeout.ShouldBeGreaterThan(TimeSpan.Zero); options.SilentChecksBeforeAlarm.ShouldBeGreaterThanOrEqualTo(1); options.StopGrace.ShouldBeLessThanOrEqualTo(TimeSpan.FromMinutes(10)); } } /// Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt. public sealed class InstanceLogExcerptTests : IDisposable { private readonly string _instanz = Path.Combine( Path.GetTempPath(), "clawd-tests", "log-" + Guid.NewGuid().ToString("N")[..8]); public InstanceLogExcerptTests() => Directory.CreateDirectory(_instanz); public void Dispose() { try { Directory.Delete(_instanz, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } private void Schreibe(string tag, string modul, params string[] zeilen) { var dir = Path.Combine(_instanz, "Logs", tag); Directory.CreateDirectory(dir); File.WriteAllLines(Path.Combine(dir, modul), zeilen); } [Fact] public void Ohne_Protokoll_kommt_nichts_zurueck() { // Kein Fehler, sondern ein Befund: Ein Prozess, der erschlagen wurde, bevor sein // Schreibpuffer geleert war, hinterlaesst genau nichts (#529). InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty(); } /// /// Module beschreiben denselben Zeitraum. Bei einem Absturz will man wissen, was /// gleichzeitig passiert ist — nicht erst alles aus einer Datei, dann alles aus der /// nächsten. /// [Fact] public void Die_Module_werden_nach_Zeit_zusammengefuehrt() { Schreibe("2026-09-08", "Startup.log", "[17:26:48.100] [INF] Instanz Test2 angelegt", "[17:26:50.900] [INF] Application is shutting down"); Schreibe("2026-09-08", "Web.log", "[17:26:49.500] [INF] Weboberfläche beendet"); var zeilen = InstanceLogExcerpt.Read(_instanz); zeilen.Count.ShouldBe(3); zeilen[0].ShouldContain("17:26:48"); zeilen[1].ShouldContain("17:26:49"); zeilen[2].ShouldContain("17:26:50"); // Aus welcher Datei eine Zeile stammt, gehoert dazu. zeilen[1].ShouldStartWith("Web.log:"); } [Fact] public void Nur_der_juengste_Tag_zaehlt() { Schreibe("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt"); Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch"); InstanceLogExcerpt.Read(_instanz).ShouldHaveSingleItem().ShouldContain("frisch"); } [Fact] public void Es_werden_nur_die_letzten_Zeilen_genommen() { Schreibe("2026-09-08", "Startup.log", Enumerable.Range(0, 500).Select(i => $"[10:00:{i % 60:00}.000] [INF] Zeile {i}").ToArray()); InstanceLogExcerpt.Read(_instanz, lines: 10).Count.ShouldBeLessThanOrEqualTo(10); } /// /// Die Datei kann in genau diesem Moment noch beschrieben werden — von einer Instanz, /// die gerade endet. Wer sie exklusiv öffnen wollte, bekäme im Zweifel gar nichts. /// [Fact] public void Eine_offen_beschriebene_Datei_laesst_sich_lesen() { Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile"); var pfad = Path.Combine(_instanz, "Logs", "2026-09-08", "Startup.log"); using var offen = new FileStream( pfad, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); InstanceLogExcerpt.Read(_instanz).ShouldNotBeEmpty(); } [Fact] public void Ein_eigenes_Protokollverzeichnis_wird_beachtet() { var eigen = Path.Combine(_instanz, "AndereLogs"); Directory.CreateDirectory(Path.Combine(eigen, "2026-09-08")); File.WriteAllLines( Path.Combine(eigen, "2026-09-08", "Startup.log"), ["[10:00:00.000] [INF] woanders"]); var config = new ClawdDotNet.Core.Config.InstanceConfig { WorkingDirectory = _instanz, LogDirectory = "AndereLogs" }; InstanceLogExcerpt.Read(_instanz, config).ShouldHaveSingleItem().ShouldContain("woanders"); } }