Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions MSStore.CLI.UnitTests/AppsCommandUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ public async Task AppsListCommandShouldReturnZero()
"list"
]);

result.Output.Should().ContainAll(FakeApps.Select(a => a.Id));
result.Output.Should().ContainAll(FakeApps.Select(a => a.PrimaryName));
result.Error.Should().ContainAll(FakeApps.Select(a => a.Id));
result.Error.Should().ContainAll(FakeApps.Select(a => a.PrimaryName));
}

[TestMethod]
Expand Down
7 changes: 4 additions & 3 deletions MSStore.CLI.UnitTests/BaseCommandLineTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -802,16 +802,17 @@ protected void SetupBasedOnTestDataProjectSubPath(DirectoryInfo dirInfo, string[
var outputCapture = new OutputCapture(Console.Out);
var errorCapture = RefreshAnsiConsole();

// Only stdout is redirected: the error capture is reached exclusively through
// ErrorAnsiConsole, mirroring how Program.cs keeps the two streams apart.
// Only stdout is redirected: it is reserved for StandardOutput payloads. Human-readable writes
// reach the error capture through either ErrorAnsiConsole or the static console, mirroring how
// Program.cs points both at the same instance.
Console.SetOut(outputCapture);

AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor,
Interactive = InteractionSupport.No,
Out = new CustomAnsiConsoleOutput(outputCapture),
Out = new CustomAnsiConsoleOutput(errorCapture),
Enrichment = new ProfileEnrichment
{
UseDefaultEnrichers = false
Expand Down
4 changes: 2 additions & 2 deletions MSStore.CLI.UnitTests/EmptyCommandUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task InfoCommandShouldReturnZero()

var result = await ParseAndInvokeAsync(["info"]);

result.Output.Should().Contain("Current Config");
result.Error.Should().Contain("Current Config");
}

[TestMethod]
Expand All @@ -57,7 +57,7 @@ public async Task InfoCommandShouldReturnZeroWithCert()

var result = await ParseAndInvokeAsync(["info"]);

result.Output.Should().Contain("Current Config");
result.Error.Should().Contain("Current Config");
}
}
}
4 changes: 2 additions & 2 deletions MSStore.CLI.UnitTests/FlightsCommandUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ public async Task FlightsListCommandShouldReturnZero()
FakeApps[0].Id!
]);

result.Output.Should().ContainAll(FakeFlights.Select(a => a.FlightId));
result.Output.Should().ContainAll(FakeFlights.Select(a => a.FriendlyName));
result.Error.Should().ContainAll(FakeFlights.Select(a => a.FlightId));
result.Error.Should().ContainAll(FakeFlights.Select(a => a.FriendlyName));
}

[TestMethod]
Expand Down
230 changes: 230 additions & 0 deletions MSStore.CLI.UnitTests/OutputStreamUnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using MSStore.CLI.Helpers;
using MSStore.CLI.Services;

namespace MSStore.CLI.UnitTests
{
[TestClass]
public class OutputStreamUnitTests : BaseCommandLineTest
{
[TestInitialize]
public void Init()
{
FakeLogin();
AddDefaultFakeAccount();
AddFakeApps();
}

[TestCleanup]
public void ResetOutputStreamEnvironmentVariable()
{
Environment.SetEnvironmentVariable(EnvironmentInfo.OutputStreamEnvironmentVariable, null);
}

[TestMethod]
public void ResolveDefaultsToStderr()
{
var (stream, warning) = OutputStreamResolver.Resolve([], null);

stream.Should().Be(OutputStream.Stderr);
warning.Should().BeNull();
}

[DataRow("stdout", nameof(OutputStream.Stdout))]
[DataRow("stderr", nameof(OutputStream.Stderr))]
[DataRow("STDOUT", nameof(OutputStream.Stdout))]
[DataRow("StdErr", nameof(OutputStream.Stderr))]
[TestMethod]
public void ResolveReadsTheOptionValueCaseInsensitively(string value, string expected)
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream", value], null);

stream.Should().Be(Enum.Parse<OutputStream>(expected));
warning.Should().BeNull();
}

[DataRow("--output-stream=stdout")]
[DataRow("--output-stream:stdout")]
[TestMethod]
public void ResolveSupportsInlineValueSeparators(string arg)
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish", arg], null);

stream.Should().Be(OutputStream.Stdout);
warning.Should().BeNull();
}

[TestMethod]
public void ResolveUsesTheLastOccurrenceWhenTheOptionIsRepeated()
{
var (stream, _) = OutputStreamResolver.Resolve(
["publish", "--output-stream", "stdout", "--output-stream", "stderr"],
null);

stream.Should().Be(OutputStream.Stderr);
}

[TestMethod]
public void ResolveIgnoresTheOptionWhenItHasNoValue()
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream"], null);

stream.Should().Be(OutputStream.Stderr);
warning.Should().BeNull();
}

[TestMethod]
public void ResolveDoesNotMatchOptionsThatMerelyStartWithTheSameText()
{
var (stream, _) = OutputStreamResolver.Resolve(["package", "--output-streamer", "stdout"], null);

stream.Should().Be(OutputStream.Stderr);
}

[TestMethod]
public void ResolveDoesNotConfuseTheOptionWithTheOutputDirectoryOption()
{
var (stream, _) = OutputStreamResolver.Resolve(["package", "--output", "C:\\packages"], null);

stream.Should().Be(OutputStream.Stderr);
}

[TestMethod]
public void ResolveReadsTheEnvironmentVariableWhenTheOptionIsAbsent()
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], "stdout");

stream.Should().Be(OutputStream.Stdout);
warning.Should().BeNull();
}

[DataRow("stdout", "stderr", nameof(OutputStream.Stderr))]
[DataRow("stderr", "stdout", nameof(OutputStream.Stdout))]
[TestMethod]
public void ResolveLetsTheOptionOverrideTheEnvironmentVariable(string environmentValue, string optionValue, string expected)
{
var (stream, warning) = OutputStreamResolver.Resolve(
["package", "--output-stream", optionValue],
environmentValue);

stream.Should().Be(Enum.Parse<OutputStream>(expected));
warning.Should().BeNull();
}

[TestMethod]
public void ResolveWarnsAndFallsBackWhenTheEnvironmentVariableIsInvalid()
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], "console");

stream.Should().Be(OutputStream.Stderr);
warning.Should().Contain("console");
warning.Should().Contain(EnvironmentInfo.OutputStreamEnvironmentVariable);
}

[DataRow("")]
[DataRow(" ")]
[TestMethod]
public void ResolveTreatsABlankEnvironmentVariableAsUnset(string environmentValue)
{
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], environmentValue);

stream.Should().Be(OutputStream.Stderr);
warning.Should().BeNull();
}

[TestMethod]
public void ResolveDoesNotWarnForAnInvalidOptionValue()
{
// The parser reports invalid option values, so the resolver just falls through.
var (stream, warning) = OutputStreamResolver.Resolve(["publish", "--output-stream", "console"], null);

stream.Should().Be(OutputStream.Stderr);
warning.Should().BeNull();
}

[DataRow("0")]
[DataRow("1")]
[DataRow("2")]
[TestMethod]
public void ResolveRejectsNumericEnumValues(string environmentValue)
{
// Only the two names are part of the contract, so the underlying numbers must not be accepted.
var (stream, warning) = OutputStreamResolver.Resolve(["publish"], environmentValue);

stream.Should().Be(OutputStream.Stderr);
warning.Should().Contain(environmentValue);
}

[DataRow("--output-stream", "stdout")]
[DataRow("--output-stream=stdout", null)]
[TestMethod]
public void ResolveStopsScanningAtTheEndOfOptionsMarker(string arg, string? value)
{
// System.CommandLine treats everything after `--` as a literal argument, so the resolver must too.
string[] args = value == null ? ["package", "--", arg] : ["package", "--", arg, value];

var (stream, _) = OutputStreamResolver.Resolve(args, null);

stream.Should().Be(OutputStream.Stderr);
}

[TestMethod]
public void ResolveStillReadsTheOptionBeforeTheEndOfOptionsMarker()
{
var (stream, _) = OutputStreamResolver.Resolve(
["package", "--output-stream", "stdout", "--", "--output-stream=stderr"],
null);

stream.Should().Be(OutputStream.Stdout);
}

[TestMethod]
public void ResolveReadsTheRealEnvironmentVariable()
{
Environment.SetEnvironmentVariable(EnvironmentInfo.OutputStreamEnvironmentVariable, "stdout");

var (stream, warning) = OutputStreamResolver.Resolve(["publish"]);

stream.Should().Be(OutputStream.Stdout);
warning.Should().BeNull();
}

[DataRow("stdout")]
[DataRow("stderr")]
[TestMethod]
public async Task OutputStreamOptionIsAcceptedByCommands(string value)
{
var appId = FakeApps[2].Id!;

var result = await ParseAndInvokeAsync(
[
"apps",
"get",
appId,
"--output-stream",
value
]);

// Machine-readable payloads always go to stdout, whichever stream the human-readable
// output was routed to.
result.Output.Should().Contain($"\"Id\": \"{appId}\",");
}

[TestMethod]
public async Task InvalidOutputStreamOptionValueIsRejectedByTheParser()
{
var result = await ParseAndInvokeAsync(
[
"apps",
"list",
"--output-stream",
"console"
],
1);

result.Error.Should().Contain("--output-stream");
}
}
}
27 changes: 27 additions & 0 deletions MSStore.CLI/Helpers/OutputStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace MSStore.CLI.Helpers
{
/// <summary>
/// The standard stream that human-readable console output is written to.
/// </summary>
/// <remarks>
/// This never affects machine-readable payloads, which always go to stdout through
/// <see cref="StandardOutput"/>.
/// </remarks>
internal enum OutputStream
{
/// <summary>
/// Human-readable output goes to standard error. This is the default, and keeps stdout
/// clean so payloads can be piped or captured.
/// </summary>
Stderr,

/// <summary>
/// Human-readable output goes to standard output. Useful on Azure DevOps, which renders
/// every stderr line as <c>##[error]</c>.
/// </summary>
Stdout
}
}
Loading