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 A_missing_file_is_created_with_defaults() { 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 Secrets_are_not_in_plaintext_in_the_file() { 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 again = new ManagerSettingsStore(_dir).Load(); again.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890"); again.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 A_file_with_byte_order_mark_counts_too() { var path = 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 content = """ { "instanceExecutable": "J:\\Programme\\ClawdDotNet.exe", "webUiPort": 7009, "deploymentcenterUrl": "" } """; File.WriteAllText(path, content, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); File.ReadAllBytes(path)[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 A_broken_file_does_not_prevent_the_start() { 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 Nonsensical_supervision_values_are_caught() { 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 _instance = Path.Combine( Path.GetTempPath(), "clawd-tests", "log-" + Guid.NewGuid().ToString("N")[..8]); public InstanceLogExcerptTests() => Directory.CreateDirectory(_instance); public void Dispose() { try { Directory.Delete(_instance, recursive: true); } catch { /* A leftover test folder does not turn a run red. */ } } private void Write(string tag, string module, params string[] lines) { var dir = Path.Combine(_instance, "Logs", tag); Directory.CreateDirectory(dir); File.WriteAllLines(Path.Combine(dir, module), lines); } [Fact] public void Without_log_nothing_comes_back() { // Not an error, but a finding: a process that was killed before its // write buffer was flushed leaves exactly nothing (#529). InstanceLogExcerpt.Read(_instance).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 The_modules_are_merged_by_time() { Write("2026-09-08", "Startup.log", "[17:26:48.100] [INF] Instanz Test2 angelegt", "[17:26:50.900] [INF] Application is shutting down"); Write("2026-09-08", "Web.log", "[17:26:49.500] [INF] Weboberfläche beendet"); var lines = InstanceLogExcerpt.Read(_instance); lines.Count.ShouldBe(3); lines[0].ShouldContain("17:26:48"); lines[1].ShouldContain("17:26:49"); lines[2].ShouldContain("17:26:50"); // Which file a line comes from is part of it. lines[1].ShouldStartWith("Web.log:"); } [Fact] public void Only_the_most_recent_day_counts() { Write("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt"); Write("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch"); InstanceLogExcerpt.Read(_instance).ShouldHaveSingleItem().ShouldContain("frisch"); } [Fact] public void Only_the_last_lines_are_taken() { Write("2026-09-08", "Startup.log", Enumerable.Range(0, 500).Select(i => $"[10:00:{i % 60:00}.000] [INF] Zeile {i}").ToArray()); InstanceLogExcerpt.Read(_instance, 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 A_file_open_for_writing_can_be_read() { Write("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile"); var path = Path.Combine(_instance, "Logs", "2026-09-08", "Startup.log"); using var open = new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); InstanceLogExcerpt.Read(_instance).ShouldNotBeEmpty(); } [Fact] public void A_custom_log_directory_is_respected() { var own = Path.Combine(_instance, "AndereLogs"); Directory.CreateDirectory(Path.Combine(own, "2026-09-08")); File.WriteAllLines( Path.Combine(own, "2026-09-08", "Startup.log"), ["[10:00:00.000] [INF] woanders"]); var config = new ClawdDotNet.Core.Config.InstanceConfig { WorkingDirectory = _instance, LogDirectory = "AndereLogs" }; InstanceLogExcerpt.Read(_instance, config).ShouldHaveSingleItem().ShouldContain("woanders"); } }