Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111): Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact). Sicherheit und Zielplattform #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException) verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen. #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt. Eine Schreibweise statt einer Sonderregel je Windows-Form. #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs. Agentenkommunikation #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat. #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein. #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld gelesen. Betrieb und Oberflaeche #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces an ihrer Meldung - auch ueber Taktgrenzen hinweg. #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den anderen Spalten nicht den Platz nimmt. #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste. #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar bleibt statt auf einen Wert zusammenzufallen. Hygiene #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten. #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5). #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet jetzt, WELCHER Zugangsschluessel fehlt, statt an einer KeyNotFoundException zu scheitern. #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen Prompt-Groessen kalibrieren die Schaetzung je Modell. Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten Stand geprueft, damit belegt ist, dass er den Fehler faengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.8 KiB
C#
201 lines
7.8 KiB
C#
using ClawdDotNet.App.Services;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// #376: Jedes Modul schreibt in seine eigene Datei, und eingesammelt wurde Datei für
|
|
/// Datei — also alphabetisch nach Modulname. In der Log-Ansicht standen die Zeilen
|
|
/// damit in Blöcken je Modul statt in der Reihenfolge, in der sie entstanden sind.
|
|
/// Beobachtet wurde eine Zeile von 14:34:40 über vier Zeilen von 14:34:39.
|
|
///
|
|
/// Das Dateisystem wird hier nicht abstrahiert: <see cref="LogTail"/> ist genau der
|
|
/// Umgang mit echten, nebenher wachsenden Dateien — eine Abstraktion würde das
|
|
/// Verhalten verstecken, um das es geht.
|
|
/// </summary>
|
|
public sealed class LogTailTests : IDisposable
|
|
{
|
|
private readonly string _logDirectory;
|
|
private readonly string _today;
|
|
|
|
public LogTailTests()
|
|
{
|
|
_logDirectory = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Logs");
|
|
|
|
// LogTail liest ausschließlich das Verzeichnis des heutigen Tages, und zwar
|
|
// nach Ortszeit — die Schreibseite legt es genauso an.
|
|
_today = Path.Combine(_logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
|
|
Directory.CreateDirectory(_today);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(Path.GetDirectoryName(_logDirectory)!, recursive: true); }
|
|
catch { /* Aufräumen ist Nebensache */ }
|
|
}
|
|
|
|
private void Write(string module, params string[] lines)
|
|
=> File.AppendAllLines(Path.Combine(_today, $"{module}.log"), lines);
|
|
|
|
private LogTail Tail() => new(_logDirectory);
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Reihenfolge über Moduldateien hinweg
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void Zeilen_aus_mehreren_Modulen_stehen_in_zeitlicher_Reihenfolge()
|
|
{
|
|
// Genau der beobachtete Fall: "Deploymentcenter" steht alphabetisch vor
|
|
// "Startup", seine Zeile ist aber die jüngste von allen.
|
|
Write("Deploymentcenter", "[14:34:40.246] [INF] kein Token hinterlegt");
|
|
Write("Startup",
|
|
"[14:34:39.840] [INF] ClawdDotNet startet",
|
|
"[14:34:39.871] [INF] Instanz-Verzeichnis",
|
|
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert");
|
|
|
|
var lines = Tail().ReadNew();
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
"[14:34:39.840] [INF] ClawdDotNet startet",
|
|
"[14:34:39.871] [INF] Instanz-Verzeichnis",
|
|
"[14:34:40.238] [WRN] Kein OpenRouter API-Key konfiguriert",
|
|
"[14:34:40.246] [INF] kein Token hinterlegt"
|
|
]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bei_gleichem_Zeitstempel_bleibt_die_bisherige_Ordnung()
|
|
{
|
|
// Stabil sortiert: Zwei Zeilen derselben Millisekunde dürfen nicht die Plätze
|
|
// tauschen, sonst zerfällt ein zusammenhängender Ablauf innerhalb eines Moduls.
|
|
//
|
|
// Über Moduldateien hinweg entscheidet dann der Modulname — "Alpha" vor "Beta".
|
|
// Directory.GetFiles liefert unter Linux die Verzeichnisreihenfolge; ohne die
|
|
// Sortierung in ReadNew stünde hier von Lauf zu Lauf etwas anderes.
|
|
Write("Alpha", "[10:00:00.000] [INF] alpha eins", "[10:00:00.000] [INF] alpha zwei");
|
|
Write("Beta", "[10:00:00.000] [INF] beta eins");
|
|
|
|
var lines = Tail().ReadNew();
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
"[10:00:00.000] [INF] alpha eins",
|
|
"[10:00:00.000] [INF] alpha zwei",
|
|
"[10:00:00.000] [INF] beta eins"
|
|
]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Stacktrace_bleibt_bei_seiner_Meldung()
|
|
{
|
|
// Eine Ausnahme hängt ihre Zeilen ohne eigenen Zeitstempel unter die Meldung.
|
|
// Ohne Sonderbehandlung wären sie ohne Schlüssel nach ganz vorn gerutscht.
|
|
Write("Alpha", "[10:00:00.500] [INF] danach");
|
|
Write("Engine",
|
|
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
|
|
"System.InvalidOperationException: kaputt",
|
|
" bei ClawdDotNet.Engine.Run()");
|
|
|
|
var lines = Tail().ReadNew();
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
"[10:00:00.100] [ERR] Aufruf fehlgeschlagen",
|
|
"System.InvalidOperationException: kaputt",
|
|
" bei ClawdDotNet.Engine.Run()",
|
|
"[10:00:00.500] [INF] danach"
|
|
]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ein_Stacktrace_bleibt_auch_ueber_die_Taktgrenze_bei_seiner_Meldung()
|
|
{
|
|
// Der Takt kann mitten in einem Eintrag enden: Die Meldung ist gelesen, die
|
|
// Fortsetzung kommt erst beim nächsten Aufruf. Der Zeitstempel muss dann aus
|
|
// dem vorigen Takt weitergetragen werden.
|
|
var tail = Tail();
|
|
|
|
Write("Engine", "[10:00:00.100] [ERR] Aufruf fehlgeschlagen");
|
|
tail.ReadNew();
|
|
|
|
Write("Alpha", "[10:00:00.500] [INF] danach");
|
|
Write("Engine", " bei ClawdDotNet.Engine.Run()");
|
|
|
|
var lines = tail.ReadNew();
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
" bei ClawdDotNet.Engine.Run()",
|
|
"[10:00:00.500] [INF] danach"
|
|
]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Eine_Uhrzeit_mitten_in_der_Meldung_ist_kein_Sortierschluessel()
|
|
{
|
|
// Gesucht wird der Zeitstempel am Zeilenanfang, nicht irgendeine Uhrzeit im Text.
|
|
Write("Alpha", "[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]");
|
|
Write("Beta", "[10:00:00.200] [INF] danach");
|
|
|
|
var lines = Tail().ReadNew();
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
"[10:00:00.100] [INF] naechster Lauf um [23:59:59.999]",
|
|
"[10:00:00.200] [INF] danach"
|
|
]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Was die Sortierung nicht kaputtmachen darf
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public void Nur_das_Neue_kommt_zurueck()
|
|
{
|
|
var tail = Tail();
|
|
|
|
Write("Alpha", "[10:00:00.100] [INF] erste");
|
|
tail.ReadNew().Count.ShouldBe(1);
|
|
|
|
Write("Alpha", "[10:00:00.200] [INF] zweite");
|
|
tail.ReadNew().Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] zweite"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Der_Modulfilter_laesst_nur_sein_Modul_durch()
|
|
{
|
|
Write("Alpha", "[10:00:00.100] [INF] alpha");
|
|
Write("Beta", "[10:00:00.200] [INF] beta");
|
|
|
|
var lines = Tail().ReadNew(module: "Beta");
|
|
|
|
lines.Select(l => l.Text).ShouldBe(["[10:00:00.200] [INF] beta"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Eine_weggefilterte_Zeile_traegt_ihren_Zeitstempel_trotzdem_weiter()
|
|
{
|
|
// Der Stufenfilter darf die Zuordnung der Fortsetzungszeilen nicht verlieren:
|
|
// Die Meldung fällt weg, ihr Stacktrace nicht — er hat keine eigene Stufe und
|
|
// wird bewusst durchgelassen. Er muss dann an ihrer Stelle stehen.
|
|
Write("Alpha", "[10:00:00.500] [ERR] spaeter Fehler");
|
|
Write("Engine",
|
|
"[10:00:00.100] [INF] harmlos",
|
|
" Fortsetzung ohne Stufe");
|
|
|
|
var lines = Tail().ReadNew(level: LogLevelFilter.Error);
|
|
|
|
lines.Select(l => l.Text).ShouldBe([
|
|
" Fortsetzung ohne Stufe",
|
|
"[10:00:00.500] [ERR] spaeter Fehler"
|
|
]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ohne_Tagesverzeichnis_kommt_eine_leere_Liste()
|
|
{
|
|
Directory.Delete(_today, recursive: true);
|
|
|
|
Tail().ReadNew().ShouldBeEmpty();
|
|
}
|
|
}
|