diff --git a/RunCommand.Test/LineOutputHandlerTests.cs b/RunCommand.Test/LineOutputHandlerTests.cs index 46f2e94..3b397c8 100644 --- a/RunCommand.Test/LineOutputHandlerTests.cs +++ b/RunCommand.Test/LineOutputHandlerTests.cs @@ -7,6 +7,9 @@ public class LineOutputHandlerTests { private static readonly string[] ExpectedXY = ["x", "y"]; private static readonly string[] ExpectedEveryLineEndingKind = ["a", "b", "c", "d", ""]; + private static readonly string[] ExpectedALast = ["a", "last"]; + private static readonly string[] ExpectedALastNext = ["a", "last", "next"]; + private static readonly string[] ExpectedOops = ["oops"]; [TestMethod] public void HandleStandardOutputDataShouldProcessLinesCorrectly() @@ -159,4 +162,70 @@ public void HandleStandardOutputDataShouldSplitOnEveryLineEndingKind() Assert.AreSequenceEqual(ExpectedEveryLineEndingKind, lines); Assert.AreEqual("e", handler.outputBuffer); } + + [TestMethod] + public void CompleteShouldDeliverTheFinalUnterminatedLine() + { + // Arrange + List output = []; + List error = []; + LineOutputHandler handler = new(onStandardOutput: output.Add, onStandardError: error.Add); + handler.HandleStandardOutputData("a\nlast"); + handler.HandleStandardErrorData("oops"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, output); + Assert.AreSequenceEqual(ExpectedOops, error); + Assert.AreEqual("", handler.outputBuffer); + Assert.AreEqual("", handler.errorBuffer); + } + + [TestMethod] + public void CompleteShouldNotEmitAnExtraLineWhenOutputEndsOnALineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\nlast\n"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, lines); + } + + [TestMethod] + public void CompleteShouldTreatATrailingCarriageReturnAsALineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\rlast\r"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, lines); + } + + [TestMethod] + public void CompleteShouldKeepAPartialLineFromLeakingIntoTheNextRun() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\nlast"); + handler.Complete(); + + // Act + handler.HandleStandardOutputData("next\n"); + + // Assert + Assert.AreSequenceEqual(ExpectedALastNext, lines); + } } diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index c5090f7..3a34051 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -11,6 +11,9 @@ namespace ktsu.RunCommand.Test; [TestClass] public class RunCommandTests { + private static readonly string[] ExpectedALast = ["a", "last"]; + private static readonly string[] ExpectedALastNext = ["a", "last", "next"]; + private static string GetCopyCommand(string source, string destination) => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"cmd /c copy \"{source}\" \"{destination}\"" @@ -967,4 +970,42 @@ public async Task ExecuteAsyncShouldDeliverOutputStillInThePipeWhenTheProcessExi File.Delete(path); } } + + /// + /// Returns a command that prints a file's contents unchanged. + /// + private static (string FileName, string[] Arguments) GetPrintFileCommand(string path) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ("cmd", ["/c", "type", path]) + : ("cat", [path]); + + [TestMethod] + public async Task LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun() + { + string first = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.1.txt"); + string second = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.2.txt"); + await File.WriteAllTextAsync(first, "a\nlast").ConfigureAwait(false); + await File.WriteAllTextAsync(second, "next\n").ConfigureAwait(false); + + try + { + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + + (string fileName, string[] arguments) = GetPrintFileCommand(first); + int exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false); + Assert.AreEqual(0, exitCode); + Assert.AreSequenceEqual(ExpectedALast, lines); + + (fileName, arguments) = GetPrintFileCommand(second); + exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false); + Assert.AreEqual(0, exitCode); + Assert.AreSequenceEqual(ExpectedALastNext, lines); + } + finally + { + File.Delete(first); + File.Delete(second); + } + } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index c8044ac..d9b4aab 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -52,6 +52,37 @@ internal override void HandleStandardErrorData(string data) ProcessDataByLine(data, ref errorBuffer, OnStandardError); } + /// + /// Delivers the text left in each buffer as a final line, since no line break will now arrive to end it, + /// and leaves both buffers empty so a later run on this handler starts clean. + /// + internal override void Complete() + { + FlushBuffer(ref outputBuffer, OnStandardOutput); + FlushBuffer(ref errorBuffer, OnStandardError); + } + + /// + /// Invokes with the buffered final line, if there is one, and clears the buffer. + /// + /// The buffer holding an incomplete line. + /// The action to be invoked for the final line. + /// + /// A buffer that ends in a CR is a line whose break had not yet been confirmed as CR or CRLF. + /// At the end of the stream it is a CR on its own, so it ends the line rather than being part of it. + /// + private static void FlushBuffer(ref string buffer, Action? onLineReceived) + { + if (buffer.Length == 0) + { + return; + } + + string line = buffer[^1] == '\r' ? buffer[..^1] : buffer; + buffer = ""; + onLineReceived?.Invoke(line); + } + /// /// Processes the data by line, invoking the specified action for each line received. /// diff --git a/RunCommand/OutputHandler.cs b/RunCommand/OutputHandler.cs index 5647ff9..4e63a9f 100644 --- a/RunCommand/OutputHandler.cs +++ b/RunCommand/OutputHandler.cs @@ -60,4 +60,10 @@ internal virtual void HandleStandardErrorData(string data) Ensure.NotNull(data); OnStandardError?.Invoke(data); } + + /// + /// Called once both output streams have been read to their end and the process has exited, + /// so a handler that holds back partial data can deliver it. Not called when the run is cancelled. + /// + internal virtual void Complete() { } } diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 3ea6e54..e3da840 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -492,6 +492,13 @@ private static async Task RunAsync(ProcessStartInfo startInfo, OutputHandle // never mistaken for a command that genuinely failed. cancellationToken.ThrowIfCancellationRequested(); + if (!useElevation) + { + // Both streams have been read to their end, so anything a handler is still holding back + // waiting for a line break will never get one. + outputHandler.Complete(); + } + return process.ExitCode; }