using ClawdDotNet.Core.Instances; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// manager.json and the log excerpt — the two places where the manager /// talks to the file system (#528, stage 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 { /* A leftover test folder does not turn a run red. */ } } [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 and license key are not in plain text in the file — but they are at /// runtime, otherwise nobody could work with them. /// [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"); } /// /// A file written by hand or by PowerShell often has a byte order mark, /// and it must still apply. /// /// In the acceptance run it looked as if it were ignored — the manager worked /// with defaults. This test clarifies whether that was due to the file. /// [Fact] public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch() { var pfad = Path.Combine(_dir, "manager.json"); // Exactly as PowerShell writes it: with BOM, with double spaces after // the colon, and with doubled backslashes in the path. 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(""); } /// /// An unreadable file must not prevent the manager from starting — then /// no instance would run at all, and that is the worse state. /// [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); // And the reason is recorded - otherwise the manager silently works with // defaults, and nobody can say why its instance directory is wrong. store.LoadProblem.ShouldNotBeNullOrWhiteSpace(); } /// /// Nonsensical numbers can be in a hand-editable file. A /// probe interval of 0 would be an endless loop, an alarm after zero failed attempts an /// alarm at every hiccup. /// [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)); } } /// The log excerpt that only gives a crash report its value. 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 { /* A leftover test folder does not turn a run red. */ } } 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() { // Not an error, but a finding: a process that was killed before its // write buffer was flushed leaves exactly nothing (#529). InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty(); } /// /// Modules describe the same period. With a crash you want to know what /// happened at the same time — not first everything from one file, then everything from the /// next. /// [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"); // Which file a line comes from is part of it. 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); } /// /// The file may still be written to at exactly this moment — by an instance /// that is just ending. Whoever wanted to open it exclusively might get nothing at all. /// [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"); } }