using System.Text; using AcDream.Launcher.Core.Launching; namespace AcDream.Launcher.Core.Tests.Launching; /// Fix #406 sibling gap: the launcher previously discarded a /// supervised child's stderr entirely, so diagnosing a crash (including /// exactly the #406 crash) required re-running the identical binary by /// hand. These tests cover in /// isolation — the real-child-process end-to-end capture tests live in /// LauncherProcessSupervisorTests alongside the existing real-process /// coverage. public sealed class BoundedProcessOutputCaptureTests { [Fact] public void AppendLineWritesEachLineWithATrailingNewline() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture(path); capture.AppendLine("first"); capture.AppendLine("second"); capture.Dispose(); Assert.Equal("first\nsecond\n", File.ReadAllText(path)); } finally { TryDelete(path); } } [Fact] public void ANullLineFromTheEndOfStreamSentinelIsANoOp() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture(path); capture.AppendLine("kept"); capture.AppendLine(null); capture.Dispose(); Assert.Equal("kept\n", File.ReadAllText(path)); } finally { TryDelete(path); } } [Fact] public void WritesBeyondTheCapAreDroppedAndAOneTimeTruncationMarkerIsAppended() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture(path, maxBytes: 16); capture.AppendLine("0123456789"); // 11 bytes incl. newline capture.AppendLine("this line is dropped entirely"); capture.AppendLine("so is this one"); Assert.True(capture.IsDone); string written = File.ReadAllText(path); Assert.StartsWith("0123456789\n", written, StringComparison.Ordinal); Assert.Contains("truncated at 16 bytes", written, StringComparison.Ordinal); // The cap is a hard ceiling: nothing past it EVER lands on disk, // even the marker's own text does not push the file arbitrarily // far past the configured bound. Assert.True( written.Length < 200, $"expected a small bounded file, got {written.Length} bytes"); } finally { TryDelete(path); } } [Fact] public void ALogSpammingChildCannotGrowTheFileUnboundedly() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture( path, maxBytes: BoundedProcessOutputCapture.DefaultMaxBytes); // Far more than the 2 MiB default cap. string spamLine = new('x', 4096); for (int i = 0; i < 4096; i++) { capture.AppendLine(spamLine); if (capture.IsDone) { break; } } Assert.True(capture.IsDone); long fileLength = new FileInfo(path).Length; Assert.True( fileLength < BoundedProcessOutputCapture.DefaultMaxBytes + 256, $"expected the file to stay near the {BoundedProcessOutputCapture.DefaultMaxBytes}-byte " + $"cap, got {fileLength} bytes"); } finally { TryDelete(path); } } [Fact] public void AppendCreatesTheSessionDirectoryOnFirstWrite() { string directory = Path.Combine( Path.GetTempPath(), "acdream-406-capture-" + Guid.NewGuid().ToString("N")); string path = Path.Combine(directory, "client.err.log"); Assert.False(Directory.Exists(directory)); try { using var capture = new BoundedProcessOutputCapture(path); capture.AppendLine("hello"); capture.Dispose(); Assert.True(File.Exists(path)); } finally { try { Directory.Delete(directory, recursive: true); } catch (IOException) { } } } [Fact] public void RawByteAppendsAreConcatenatedWithoutAnImpliedLineBoundary() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture(path); capture.Append(Encoding.UTF8.GetBytes("abc")); capture.Append(Encoding.UTF8.GetBytes("def")); capture.Dispose(); Assert.Equal("abcdef", File.ReadAllText(path)); } finally { TryDelete(path); } } [Fact] public void EmptyAppendsAreNoOps() { string path = TempPath(); try { using var capture = new BoundedProcessOutputCapture(path); capture.Append(ReadOnlySpan.Empty); capture.AppendLine(string.Empty); capture.Dispose(); // An empty string line still gets its trailing newline — // only a genuinely zero-length byte span (or a null line) is // a true no-op. Assert.Equal("\n", File.ReadAllText(path)); } finally { TryDelete(path); } } [Fact] public void AppendAfterDisposeIsASilentNoOp() { string path = TempPath(); try { var capture = new BoundedProcessOutputCapture(path); capture.AppendLine("before"); capture.Dispose(); capture.AppendLine("after — must not throw or reopen the file"); Assert.Equal("before\n", File.ReadAllText(path)); } finally { TryDelete(path); } } [Fact] public void ConstructorRejectsANonPositiveMaxBytes() { Assert.Throws( () => new BoundedProcessOutputCapture(TempPath(), maxBytes: 0)); Assert.Throws( () => new BoundedProcessOutputCapture(TempPath(), maxBytes: -1)); } [Fact] public void ConstructorRejectsANullOrBlankPath() { Assert.Throws(() => new BoundedProcessOutputCapture("")); Assert.Throws(() => new BoundedProcessOutputCapture(" ")); } private static string TempPath() => Path.Combine( Path.GetTempPath(), "acdream-406-capture-" + Guid.NewGuid().ToString("N") + ".log"); private static void TryDelete(string path) { try { File.Delete(path); } catch (IOException) { } } }