Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ internal sealed class PlatformCommandLineProvider : ICommandLineOptionsProvider
public const string NoBannerOptionKey = "no-banner";
public const string SkipBuildersNumberCheckOptionKey = "internal-testingplatform-skipbuildercheck";
public const string DiscoverTestsOptionKey = "list-tests";
public const string DiscoverTestsJsonArgument = "json";
public const string DiscoverTestsTextArgument = "text";

private static readonly string SupportedDiscoverTestsValues = $"'{DiscoverTestsTextArgument}', '{DiscoverTestsJsonArgument}'";
public const string ResultDirectoryOptionKey = "results-directory";
public const string IgnoreExitCodeOptionKey = "ignore-exit-code";
public const string MinimumExpectedTestsOptionKey = "minimum-expected-tests";
Expand Down Expand Up @@ -55,7 +59,7 @@ internal sealed class PlatformCommandLineProvider : ICommandLineOptionsProvider
new(DiagnosticVerbosityOptionKey, PlatformResources.PlatformCommandLineDiagnosticVerbosityOptionDescription, ArgumentArity.ExactlyOne, false, isBuiltIn: true),
new(DiagnosticFileLoggerSynchronousWriteOptionKey, PlatformResources.PlatformCommandLineDiagnosticFileLoggerSynchronousWriteOptionDescription, ArgumentArity.Zero, false, isBuiltIn: true),
MinimumExpectedTests,
new(DiscoverTestsOptionKey, PlatformResources.PlatformCommandLineDiscoverTestsOptionDescription, ArgumentArity.Zero, false, isBuiltIn: true),
new(DiscoverTestsOptionKey, PlatformResources.PlatformCommandLineDiscoverTestsOptionDescription, ArgumentArity.ZeroOrOne, false, isBuiltIn: true),
new(IgnoreExitCodeOptionKey, PlatformResources.PlatformCommandLineIgnoreExitCodeOptionDescription, ArgumentArity.ExactlyOne, false, isBuiltIn: true),
new(ExitOnProcessExitOptionKey, PlatformResources.PlatformCommandLineExitOnProcessExitOptionDescription, ArgumentArity.ExactlyOne, false, isBuiltIn: true),
new(ConfigFileOptionKey, PlatformResources.PlatformCommandLineConfigFileOptionDescription, ArgumentArity.ExactlyOne, false, isBuiltIn: true),
Expand Down Expand Up @@ -101,6 +105,14 @@ public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption com
}
}

if (commandOption.Name == DiscoverTestsOptionKey
&& arguments.Length == 1
&& !DiscoverTestsJsonArgument.Equals(arguments[0], StringComparison.OrdinalIgnoreCase)
&& !DiscoverTestsTextArgument.Equals(arguments[0], StringComparison.OrdinalIgnoreCase))
{
return ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, PlatformResources.PlatformCommandLineDiscoverTestsInvalidArgument, arguments[0], SupportedDiscoverTestsValues));
}

if (commandOption.Name == ClientPortOptionKey && (!int.TryParse(arguments[0], out int _)))
{
return ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, PlatformResources.PlatformCommandLinePortOptionSingleArgument, ClientPortOptionKey));
Expand Down Expand Up @@ -156,6 +168,11 @@ public static int GetMinimumExpectedTests(ICommandLineOptions commandLineOptions
return int.Parse(arguments[0], CultureInfo.InvariantCulture);
}

public static bool IsListTestsJsonOutput(ICommandLineOptions commandLineOptions)
=> commandLineOptions.TryGetOptionArgumentList(DiscoverTestsOptionKey, out string[]? arguments)
&& arguments is { Length: 1 }
&& DiscoverTestsJsonArgument.Equals(arguments[0], StringComparison.OrdinalIgnoreCase);

private static Task<ValidationResult> IsMinimumExpectedTestsOptionValidAsync(CommandLineOption option, string[] arguments)
=> option.Name == MinimumExpectedTestsOptionKey
&& (arguments.Length != 1 || !int.TryParse(arguments[0], out int value) || value == 0)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Testing.Platform.Extensions.Messages;

namespace Microsoft.Testing.Platform.OutputDevice;

/// <summary>
/// Serializes the result of <c>--list-tests json</c> into a single JSON document with as much
/// information as is available for each <see cref="TestNode"/>.
/// </summary>
/// <remarks>
/// Schema history (bump <see cref="SchemaVersion"/> on any breaking change):
/// <list type="number">
/// <item>
/// <description>
/// Initial schema. Top-level: <c>schemaVersion</c> (int), <c>tests</c> (array of test
/// objects). Per test: <c>uid</c>, <c>displayName</c>, plus optional <c>type</c>
/// (assembly / namespace / type / method / arity / params / return — from
/// <see cref="TestMethodIdentifierProperty"/>), <c>location</c> (file path, start line,
/// end line — from <see cref="TestFileLocationProperty"/>), <c>traits</c> (array of
/// <c>{ key, value }</c> from <see cref="TestMetadataProperty"/>), <c>properties</c>
/// (array of <c>{ key, value }</c> from
/// <see cref="SerializableKeyValuePairStringProperty"/>; array — not object — so
/// duplicate keys survive serialization). Absent fields are omitted;
/// <c>type.namespace</c> is omitted for the global namespace.
/// </description>
/// </item>
/// </list>
/// </remarks>
internal static class DiscoveredTestsJsonSerializer
{
/// <summary>
/// Schema version of the produced JSON document. Increment when introducing a breaking change.
/// </summary>
internal const int SchemaVersion = 1;

/// <summary>
/// Serializes the discovered tests to a pretty-printed JSON document.
/// </summary>
/// <param name="tests">The discovered tests.</param>
/// <returns>The JSON document.</returns>
public static string Serialize(IEnumerable<TestNode> tests)
{
var writer = new JsonStringWriter();
writer.WriteStartObject();
writer.WriteNumber("schemaVersion", SchemaVersion);
writer.WriteStartArray("tests");
foreach (TestNode test in tests)
{
WriteTestNode(writer, test);
}

writer.WriteEndArray();
writer.WriteEndObject();
return writer.ToString();
}

private static void WriteTestNode(JsonStringWriter writer, TestNode test)
{
writer.WriteStartObject();
writer.WriteString("uid", test.Uid.Value);
writer.WriteString("displayName", test.DisplayName);

TestMethodIdentifierProperty? methodIdentifier = test.Properties.SingleOrDefault<TestMethodIdentifierProperty>();
if (methodIdentifier is not null)
{
writer.WriteStartObject("type");
writer.WriteString("assemblyFullName", methodIdentifier.AssemblyFullName);
if (!RoslynString.IsNullOrEmpty(methodIdentifier.Namespace))
{
writer.WriteString("namespace", methodIdentifier.Namespace);
}

writer.WriteString("typeName", methodIdentifier.TypeName);
writer.WriteString("methodName", methodIdentifier.MethodName);
writer.WriteNumber("methodArity", methodIdentifier.MethodArity);
writer.WriteString("returnTypeFullName", methodIdentifier.ReturnTypeFullName);
writer.WriteStartArray("parameterTypeFullNames");
foreach (string parameterFullName in methodIdentifier.ParameterTypeFullNames)
{
writer.WriteStringValue(parameterFullName);
}

writer.WriteEndArray();
writer.WriteEndObject();
}

TestFileLocationProperty? fileLocation = test.Properties.SingleOrDefault<TestFileLocationProperty>();
if (fileLocation is not null)
{
writer.WriteStartObject("location");
writer.WriteString("file", fileLocation.FilePath);
writer.WriteNumber("lineStart", fileLocation.LineSpan.Start.Line);
writer.WriteNumber("lineEnd", fileLocation.LineSpan.End.Line);
writer.WriteEndObject();
}

TestMetadataProperty[] traits = test.Properties.OfType<TestMetadataProperty>();
if (traits.Length > 0)
{
// PropertyBag stores properties as a linked list and prepends on Add, so OfType walks
// them in reverse insertion order. Reverse here so the JSON reflects the order in
// which the adapter recorded the traits, which is what consumers will reasonably
// expect and what makes diffs stable across runs.
Array.Reverse(traits);
writer.WriteStartArray("traits");
foreach (TestMetadataProperty trait in traits)
{
writer.WriteStartObject();
writer.WriteString("key", trait.Key);
writer.WriteString("value", trait.Value);
writer.WriteEndObject();
}

writer.WriteEndArray();
}

SerializableKeyValuePairStringProperty[] kvps = test.Properties.OfType<SerializableKeyValuePairStringProperty>();
if (kvps.Length > 0)
{
// Same rationale as traits above: reverse so the JSON reflects insertion order.
// Emit as an array of {key, value} (mirroring traits) so duplicate keys — which
// PropertyBag allows — survive serialization. A JSON object would silently collapse them.
Array.Reverse(kvps);
writer.WriteStartArray("properties");
foreach (SerializableKeyValuePairStringProperty kvp in kvps)
{
writer.WriteStartObject();
writer.WriteString("key", kvp.Key);
writer.WriteString("value", kvp.Value);
writer.WriteEndObject();
}

writer.WriteEndArray();
}

writer.WriteEndObject();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.Testing.Platform.OutputDevice;

/// <summary>
/// Minimal pretty-printing JSON writer used to serialize the <c>--list-tests json</c> document.
/// Avoids depending on either <c>System.Text.Json</c> (only available on .NET) or <c>Jsonite</c>
/// (only compiled on netstandard2.0) so the same code can run on every supported target.
/// </summary>
internal sealed class JsonStringWriter
{
private readonly StringBuilder _builder = new();
private readonly Stack<ContainerState> _stack = new();

public void WriteStartObject(string? name = null) => WriteStartContainer(name, ContainerKind.Object);

public void WriteEndObject() => WriteEndContainer(ContainerKind.Object);

public void WriteStartArray(string name) => WriteStartContainer(name, ContainerKind.Array);

public void WriteEndArray() => WriteEndContainer(ContainerKind.Array);

public void WriteString(string name, string? value)
{
WritePropertyHead(name);
WriteEscapedString(value);
}

public void WriteStringValue(string? value)
{
WriteValueHead();
WriteEscapedString(value);
}

public void WriteNumber(string name, int value)
{
WritePropertyHead(name);
_builder.Append(value.ToString(CultureInfo.InvariantCulture));
}

public override string ToString() => _builder.ToString();

private void WriteStartContainer(string? name, ContainerKind kind)
{
if (name is null)
{
WriteValueHead();
}
else
{
WritePropertyHead(name);
}

_builder.Append(kind == ContainerKind.Object ? '{' : '[');
_stack.Push(new ContainerState(kind));
}

private void WriteEndContainer(ContainerKind kind)
{
ContainerState state = _stack.Pop();
if (state.Kind != kind)
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "JSON writer mismatch: expected to close {0} but got {1}.", state.Kind, kind));
}

if (state.HasMembers)
{
_builder.Append('\n');
AppendIndent();
}

_builder.Append(kind == ContainerKind.Object ? '}' : ']');
}

private void WritePropertyHead(string name)
{
WriteSeparatorAndIndent();
WriteEscapedString(name);
_builder.Append(": ");
}

private void WriteValueHead() => WriteSeparatorAndIndent();

private void WriteSeparatorAndIndent()
{
if (_stack.Count > 0)
{
ContainerState top = _stack.Peek();
if (top.HasMembers)
{
_builder.Append(',');
}

top.HasMembers = true;
}

_builder.Append('\n');
AppendIndent();
}

private void AppendIndent() => _builder.Append(' ', _stack.Count * 2);

private void WriteEscapedString(string? value)
{
if (value is null)
{
_builder.Append("null");
return;
}

_builder.Append('"');
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
switch (c)
{
case '\\':
_builder.Append("\\\\");
break;
case '"':
_builder.Append("\\\"");
break;
case '\b':
_builder.Append("\\b");
break;
case '\f':
_builder.Append("\\f");
break;
case '\n':
_builder.Append("\\n");
break;
case '\r':
_builder.Append("\\r");
break;
case '\t':
_builder.Append("\\t");
break;
case '\u2028':
case '\u2029':
// Valid JSON but invalid in JavaScript string literals (legacy eval() concern);
// System.Text.Json escapes these by default so we mirror that.
AppendUnicodeEscape(c);
break;
default:
if (c < 0x20)
{
AppendUnicodeEscape(c);
}
else if (char.IsHighSurrogate(c))
{
if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
{
_builder.Append(c);
_builder.Append(value[++i]);
}
else
{
_builder.Append('\uFFFD');
}
}
else if (char.IsLowSurrogate(c))
{
// Unpaired low surrogate.
_builder.Append('\uFFFD');
}
else
{
_builder.Append(c);
}

break;
}
}

_builder.Append('"');
}

private void AppendUnicodeEscape(char c)
=> _builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));

private enum ContainerKind
{
Object,
Array,
}

private sealed class ContainerState(ContainerKind kind)
{
public ContainerKind Kind { get; } = kind;

public bool HasMembers { get; set; }
}
}
Loading
Loading