Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions RunCommand.Test/LineOutputHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -159,4 +162,70 @@ public void HandleStandardOutputDataShouldSplitOnEveryLineEndingKind()
Assert.AreSequenceEqual(ExpectedEveryLineEndingKind, lines);
Assert.AreEqual("e", handler.outputBuffer);
}

[TestMethod]
public void CompleteShouldDeliverTheFinalUnterminatedLine()
{
// Arrange
List<string> output = [];
List<string> 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<string> 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<string> 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<string> lines = [];
LineOutputHandler handler = new(onStandardOutput: lines.Add);
handler.HandleStandardOutputData("a\nlast");
handler.Complete();

// Act
handler.HandleStandardOutputData("next\n");

// Assert
Assert.AreSequenceEqual(ExpectedALastNext, lines);
}
}
41 changes: 41 additions & 0 deletions RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
[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}\""
Expand Down Expand Up @@ -967,4 +970,42 @@
File.Delete(path);
}
}

/// <summary>
/// Returns a command that prints a file's contents unchanged.
/// </summary>
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);

Check warning on line 987 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaDgf50OHLoqijiDdZbg&open=AaDgf50OHLoqijiDdZbg&pullRequest=91
await File.WriteAllTextAsync(second, "next\n").ConfigureAwait(false);

Check warning on line 988 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaDgf50OHLoqijiDdZbh&open=AaDgf50OHLoqijiDdZbh&pullRequest=91

try
{
List<string> lines = [];
LineOutputHandler handler = new(onStandardOutput: lines.Add);

(string fileName, string[] arguments) = GetPrintFileCommand(first);
int exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);

Check warning on line 996 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaDgf50OHLoqijiDdZbi&open=AaDgf50OHLoqijiDdZbi&pullRequest=91
Assert.AreEqual(0, exitCode);
Assert.AreSequenceEqual(ExpectedALast, lines);

(fileName, arguments) = GetPrintFileCommand(second);
exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);

Check warning on line 1001 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaDgf50OHLoqijiDdZbj&open=AaDgf50OHLoqijiDdZbj&pullRequest=91
Assert.AreEqual(0, exitCode);
Assert.AreSequenceEqual(ExpectedALastNext, lines);
}
finally
{
File.Delete(first);
File.Delete(second);
}
}
}
31 changes: 31 additions & 0 deletions RunCommand/LineOutputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ internal override void HandleStandardErrorData(string data)
ProcessDataByLine(data, ref errorBuffer, OnStandardError);
}

/// <summary>
/// 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.
/// </summary>
internal override void Complete()
{
FlushBuffer(ref outputBuffer, OnStandardOutput);
FlushBuffer(ref errorBuffer, OnStandardError);
}

/// <summary>
/// Invokes <paramref name="onLineReceived"/> with the buffered final line, if there is one, and clears the buffer.
/// </summary>
/// <param name="buffer">The buffer holding an incomplete line.</param>
/// <param name="onLineReceived">The action to be invoked for the final line.</param>
/// <remarks>
/// 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.
/// </remarks>
private static void FlushBuffer(ref string buffer, Action<string>? onLineReceived)
{
if (buffer.Length == 0)
{
return;
}

string line = buffer[^1] == '\r' ? buffer[..^1] : buffer;
buffer = "";
onLineReceived?.Invoke(line);
}

/// <summary>
/// Processes the data by line, invoking the specified action for each line received.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions RunCommand/OutputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ internal virtual void HandleStandardErrorData(string data)
Ensure.NotNull(data);
OnStandardError?.Invoke(data);
}

/// <summary>
/// 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.
/// </summary>
internal virtual void Complete() { }
}
7 changes: 7 additions & 0 deletions RunCommand/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
/// </summary>
/// <param name="command">The command to execute.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 26 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 26 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]
public static int Execute(string command) =>
ExecuteAsync(command).Result;
Expand All @@ -34,7 +34,7 @@
/// <param name="command">The command to execute.</param>
/// <param name="outputHandler">The handler for processing command output.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 37 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 37 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]
public static int Execute(string command, OutputHandler outputHandler) =>
ExecuteAsync(command, outputHandler).Result;
Expand All @@ -45,7 +45,7 @@
/// <param name="command">The command to execute.</param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 48 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 48 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]
public static int Execute(string command, Elevation elevation) =>
ExecuteAsync(command, elevation).Result;
Expand All @@ -61,7 +61,7 @@
/// </param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 64 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 64 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]
public static int Execute(string command, OutputHandler outputHandler, Elevation elevation) =>
ExecuteAsync(command, outputHandler, elevation).Result;
Expand Down Expand Up @@ -108,7 +108,7 @@
/// </summary>
/// <param name="command">The command to execute.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 111 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.

Check warning on line 111 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]
public static async Task<int> ExecuteAsync(string command)
=> await ExecuteAsync(command, new OutputHandler()).ConfigureAwait(false);
Expand Down Expand Up @@ -492,6 +492,13 @@
// 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;
}

Expand Down
Loading