Files
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

241 lines
8.4 KiB
C#

using ClawdDotNet.Core.Instances;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// <c>manager.json</c> and the log excerpt — the two places where the manager
/// talks to the file system (#528, stage 3).
/// </summary>
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");
}
/// <summary>
/// Master token and license key are not in plain text in the file — but they are at
/// runtime, otherwise nobody could work with them.
/// </summary>
[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");
}
/// <summary>
/// A file written by hand or by PowerShell often has a byte order mark,
/// and it must still apply.
///
/// <para>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.</para>
/// </summary>
[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("");
}
/// <summary>
/// An unreadable file must not prevent the manager from starting — then
/// no instance would run at all, and that is the worse state.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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));
}
}
/// <summary>The log excerpt that only gives a crash report its value.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
}