diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx b/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx index edfa14356280..21f9e8c0efc2 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx @@ -452,7 +452,7 @@ This is equivalent to deleting project.assets.json. Include packages with symbols in addition to regular packages in output directory. - List the discovered tests instead of running the tests. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. Don't allow updating project lock file. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs index e5f3e710b437..4324322d757a 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs @@ -116,11 +116,16 @@ public sealed class MicrosoftTestingPlatform : TestCommandDefinition, ICustomHel public const string ListTestsOptionName = "--list-tests"; - public readonly Option ListTestsOption = new(ListTestsOptionName) + public const string ListTestsFormatText = "text"; + + public const string ListTestsFormatJson = "json"; + + public readonly Option ListTestsOption = new Option(ListTestsOptionName) { Description = CommandDefinitionStrings.CmdListTestsDescription, - Arity = ArgumentArity.Zero - }; + HelpName = $"{ListTestsFormatText}|{ListTestsFormatJson}", + Arity = ArgumentArity.ZeroOrOne + }.AcceptOnlyFromAmong(ListTestsFormatText, ListTestsFormatJson); public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf index 4107bb442ff2..68bae796cb66 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf @@ -377,8 +377,8 @@ Jedná se o ekvivalent odstranění project.assets.json. - List the discovered tests instead of running the tests. - Místo spuštění testů vypíše seznam zjištěných testů. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Místo spuštění testů vypíše seznam zjištěných testů. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf index c97e3770bf65..06d8c1e3b814 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf @@ -377,8 +377,8 @@ Dies entspricht dem Löschen von "project.assets.json". - List the discovered tests instead of running the tests. - Hiermit werden die gefundenen Tests aufgelistet, anstatt sie auszuführen. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Hiermit werden die gefundenen Tests aufgelistet, anstatt sie auszuführen. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf index ea43f5124156..4103262ac8ae 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf @@ -377,8 +377,8 @@ Esta acción es equivalente a eliminar project.assets.json. - List the discovered tests instead of running the tests. - Enumera las pruebas detectadas en vez de ejecutar las pruebas. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Enumera las pruebas detectadas en vez de ejecutar las pruebas. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf index 9c1788cf4492..42e3f0dfc3e8 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf @@ -377,8 +377,8 @@ Cela équivaut à supprimer project.assets.json. - List the discovered tests instead of running the tests. - Listez les tests découverts au lieu d'exécuter les tests. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Listez les tests découverts au lieu d'exécuter les tests. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf index 710e08e0e5f7..1bfcf905f611 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf @@ -377,8 +377,8 @@ Equivale a eliminare project.assets.json. - List the discovered tests instead of running the tests. - Elenca i testi individuati invece di eseguirli. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Elenca i testi individuati invece di eseguirli. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf index 94ab9cf4a3ef..f87c0b8548b4 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf @@ -377,8 +377,8 @@ This is equivalent to deleting project.assets.json. - List the discovered tests instead of running the tests. - テストを実行する代わりに、検出されたテストを一覧表示します。 + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + テストを実行する代わりに、検出されたテストを一覧表示します。 diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf index 22a0c8d36724..ad9d6b502ea0 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf @@ -377,8 +377,8 @@ project.assets.json을 삭제하는 것과 동일합니다. - List the discovered tests instead of running the tests. - 테스트 실행 대신 검색한 테스트를 나열합니다. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + 테스트 실행 대신 검색한 테스트를 나열합니다. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf index 2edd2dafa207..e107c0aaebe2 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf @@ -377,8 +377,8 @@ Jest to równoważne usunięciu pliku project.assets.json. - List the discovered tests instead of running the tests. - Wyświetl listę odnalezionych testów zamiast uruchamiać testy. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Wyświetl listę odnalezionych testów zamiast uruchamiać testy. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf index 165b9c528f4d..b3af323a3070 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf @@ -377,8 +377,8 @@ Isso equivale a excluir o project.assets.json. - List the discovered tests instead of running the tests. - Listar os testes detectados em vez de executar os testes. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Listar os testes detectados em vez de executar os testes. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf index 6613a0e838a9..750ce8d4585d 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf @@ -377,8 +377,8 @@ This is equivalent to deleting project.assets.json. - List the discovered tests instead of running the tests. - Вывод списка обнаруженных тестов вместо выполнения тестов. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Вывод списка обнаруженных тестов вместо выполнения тестов. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf index a45d63776bd4..855cef41b275 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf @@ -377,8 +377,8 @@ project.assets.json öğesini silmeyle eşdeğerdir. - List the discovered tests instead of running the tests. - Testleri çalıştırmak yerine bulunan testleri listeler. + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + Testleri çalıştırmak yerine bulunan testleri listeler. diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf index a31c0e218037..f457960074b9 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf @@ -377,8 +377,8 @@ This is equivalent to deleting project.assets.json. - List the discovered tests instead of running the tests. - 列出已发现的测试,而不是运行测试。 + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + 列出已发现的测试,而不是运行测试。 diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf index 261e34596969..180a9e4ac947 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf +++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf @@ -377,8 +377,8 @@ This is equivalent to deleting project.assets.json. - List the discovered tests instead of running the tests. - 列出探索到的測試,而非執行測試。 + List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. + 列出探索到的測試,而非執行測試。 diff --git a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs index 51193f4c51b9..d8e6be210dba 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs @@ -78,6 +78,7 @@ public int Run(ParseResult parseResult, bool isHelp) var testOptions = new TestOptions( IsHelp: isHelp, IsDiscovery: parseResult.HasOption(definition.ListTestsOption), + ListTestsFormat: GetListTestsFormat(parseResult, definition), EnvironmentVariables: parseResult.GetValue(definition.EnvOption) ?? ImmutableDictionary.Empty); var output = InitializeOutput(degreeOfParallelism, parseResult, testOptions); @@ -110,6 +111,16 @@ public int Run(ParseResult parseResult, bool isHelp) } } + private static TestListFormat GetListTestsFormat(ParseResult parseResult, TestCommandDefinition.MicrosoftTestingPlatform definition) + { + // '--list-tests' has ZeroOrOne arity. A bare '--list-tests' (no value) defaults to text. + // The accepted values are constrained to 'text'/'json' by the option definition. + string? value = parseResult.GetValue(definition.ListTestsOption); + return string.Equals(value, TestCommandDefinition.MicrosoftTestingPlatform.ListTestsFormatJson, StringComparison.Ordinal) + ? TestListFormat.Json + : TestListFormat.Text; + } + private static TerminalTestReporter InitializeOutput(int degreeOfParallelism, ParseResult parseResult, TestOptions testOptions) { var definition = (TestCommandDefinition.MicrosoftTestingPlatform)parseResult.CommandResult.Command; @@ -119,6 +130,16 @@ private static TerminalTestReporter InitializeOutput(int degreeOfParallelism, Pa var noProgress = parseResult.HasOption(definition.NoProgressOption); var noAnsi = parseResult.HasOption(definition.NoAnsiOption); + // When emitting machine-readable JSON discovery output, stdout must contain only the JSON + // document. Force off ANSI, progress rendering and the per-assembly "Discovering tests from..." + // banners so nothing else is interleaved with the JSON. + bool isJsonDiscovery = testOptions.IsDiscovery && testOptions.ListTestsFormat == TestListFormat.Json; + if (isJsonDiscovery) + { + noProgress = true; + noAnsi = true; + } + // TODO: Replace this with proper CI detection that we already have in telemetry. https://github.com/microsoft/testfx/issues/5533#issuecomment-2838893327 bool inCI = string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); @@ -142,9 +163,10 @@ private static TerminalTestReporter InitializeOutput(int degreeOfParallelism, Pa ShowProgress = !noProgress, ShowActiveTests = !noProgress && ansiMode == AnsiMode.AnsiIfPossible, AnsiMode = ansiMode, - ShowAssembly = true, - ShowAssemblyStartAndComplete = true, + ShowAssembly = !isJsonDiscovery, + ShowAssemblyStartAndComplete = !isJsonDiscovery, MinimumExpectedTests = parseResult.GetValue(definition.MinimumExpectedTestsOption), + ListTestsFormat = testOptions.ListTestsFormat, }); // Ctrl+C handling is wired in Run() through CtrlCCancellationManager so that diff --git a/src/Cli/dotnet/Commands/Test/MTP/Options.cs b/src/Cli/dotnet/Commands/Test/MTP/Options.cs index 43aa83809a8d..ea25d232d2d7 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Options.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Options.cs @@ -5,7 +5,20 @@ namespace Microsoft.DotNet.Cli.Commands.Test; -internal record TestOptions(bool IsHelp, bool IsDiscovery, IReadOnlyDictionary EnvironmentVariables); +internal enum TestListFormat +{ + /// + /// Human-readable discovery output (the default when '--list-tests' is passed without a value). + /// + Text, + + /// + /// Machine-readable JSON discovery output ('--list-tests json'). + /// + Json, +} + +internal record TestOptions(bool IsHelp, bool IsDiscovery, TestListFormat ListTestsFormat, IReadOnlyDictionary EnvironmentVariables); internal record PathOptions(string? ProjectOrSolutionPath, string? SolutionPath, string? TestModules, string? ResultsDirectoryPath, string? ConfigFilePath, string? DiagnosticOutputDirectoryPath); diff --git a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs index d0a3cc319e64..f22e45c0e540 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.DotNet.Cli.Commands.Test.IPC.Models; @@ -27,6 +28,11 @@ internal sealed partial class TerminalTestReporter : IDisposable internal const string DoubleIndentation = $"{SingleIndentation}{SingleIndentation}"; + /// + /// Schema version of the '--list-tests json' output. Bump on breaking shape changes. + /// + private const string DiscoveryJsonVersion = "1.0"; + internal Func CreateStopwatch { get; set; } = SystemStopwatch.StartNew; private readonly ConcurrentDictionary _assemblies = new(); @@ -1050,10 +1056,7 @@ public void StartCancelling() internal void TestDiscovered( string executionId, - string? displayName, - string? uid, - string? filePath, - int? lineNumber) + DiscoveredTestInfo test) { if (!_isDiscovery) { @@ -1066,12 +1069,18 @@ internal void TestDiscovered( TestProgressState asm = _assemblies[executionId]; // TODO: add mode for discovered tests to the progress bar - jajares - asm.DiscoverTest(displayName, uid, filePath, lineNumber); + asm.DiscoverTest(test); _terminalWithProgress.UpdateWorker(asm.SlotIndex); } public void AppendTestDiscoverySummary(ITerminal terminal, int? exitCode) { + if (_options.ListTestsFormat == TestListFormat.Json) + { + AppendTestDiscoveryJson(terminal); + return; + } + terminal.AppendLine(); var assemblies = _assemblies.Select(asm => asm.Value).OrderBy(a => a.Assembly).Where(a => a is not null).ToList(); @@ -1085,20 +1094,20 @@ public void AppendTestDiscoverySummary(ITerminal terminal, int? exitCode) terminal.Append(" - "); AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, assembly.Assembly, assembly.TargetFramework, assembly.Architecture); terminal.AppendLine(); - foreach ((string? displayName, string? uid, string? filePath, int? lineNumber) in assembly.DiscoveredTestNames) + foreach (DiscoveredTestInfo test in assembly.DiscoveredTestNames) { - if (displayName is not null) + if (test.DisplayName is not null) { terminal.Append(SingleIndentation); - terminal.Append(displayName); - if (!string.IsNullOrEmpty(filePath)) + terminal.Append(test.DisplayName); + if (!string.IsNullOrEmpty(test.FilePath)) { terminal.Append(" ["); - terminal.Append(filePath); - if (lineNumber is > 0) + terminal.Append(test.FilePath); + if (test.LineNumber is > 0) { terminal.Append(':'); - terminal.Append(lineNumber.Value.ToString(CultureInfo.InvariantCulture)); + terminal.Append(test.LineNumber.Value.ToString(CultureInfo.InvariantCulture)); } terminal.Append(']'); @@ -1133,6 +1142,90 @@ public void AppendTestDiscoverySummary(ITerminal terminal, int? exitCode) AppendExitCodeAndUrl(terminal, exitCode, isRun: false); } + private void AppendTestDiscoveryJson(ITerminal terminal) + { + var assemblies = _assemblies.Select(asm => asm.Value).Where(a => a is not null).OrderBy(a => a.Assembly).ToList(); + + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteString("version", DiscoveryJsonVersion); + writer.WriteStartArray("testContainers"); + + foreach (TestProgressState assembly in assemblies) + { + writer.WriteStartObject(); + writer.WriteString("assemblyPath", assembly.Assembly); + WriteNullableString(writer, "targetFramework", assembly.TargetFramework); + WriteNullableString(writer, "architecture", assembly.Architecture); + + writer.WriteStartArray("tests"); + foreach (DiscoveredTestInfo test in assembly.DiscoveredTestNames) + { + writer.WriteStartObject(); + WriteNullableString(writer, "uid", test.Uid); + WriteNullableString(writer, "displayName", test.DisplayName); + WriteNullableString(writer, "namespace", test.Namespace); + WriteNullableString(writer, "typeName", test.TypeName); + WriteNullableString(writer, "methodName", test.MethodName); + + writer.WriteStartArray("parameterTypeFullNames"); + foreach (string parameterTypeFullName in test.ParameterTypeFullNames) + { + writer.WriteStringValue(parameterTypeFullName); + } + + writer.WriteEndArray(); + + writer.WriteStartArray("traits"); + foreach ((string key, string value) in test.Traits) + { + writer.WriteStartObject(); + writer.WriteString("key", key); + writer.WriteString("value", value); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + + WriteNullableString(writer, "filePath", test.FilePath); + if (test.LineNumber is { } lineNumber) + { + writer.WriteNumber("lineNumber", lineNumber); + } + else + { + writer.WriteNull("lineNumber"); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + terminal.Append(Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length)); + terminal.AppendLine(); + } + + private static void WriteNullableString(Utf8JsonWriter writer, string propertyName, string? value) + { + if (value is null) + { + writer.WriteNull(propertyName); + } + else + { + writer.WriteString(propertyName, value); + } + } + public void AssemblyDiscoveryCompleted(int testCount) => _terminalWithProgress.WriteToTerminal(terminal => terminal.Append($"Found {testCount} tests")); diff --git a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporterOptions.cs b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporterOptions.cs index ef09f068718d..33584ba6fc9c 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporterOptions.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporterOptions.cs @@ -45,6 +45,11 @@ internal sealed class TerminalTestReporterOptions /// Gets a value indicating the ANSI mode. /// public AnsiMode AnsiMode { get; init; } + + /// + /// Gets the format used when listing discovered tests ('--list-tests'). Only relevant in discovery mode. + /// + public TestListFormat ListTestsFormat { get; init; } } internal enum AnsiMode diff --git a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TestProgressState.cs b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TestProgressState.cs index d12aeff882ba..7d05fc0a1e46 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TestProgressState.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TestProgressState.cs @@ -44,7 +44,7 @@ internal sealed class TestProgressState(long id, string assembly, string? target public long Version { get; internal set; } - public List<(string? DisplayName, string? UID, string? FilePath, int? LineNumber)> DiscoveredTestNames { get; internal set; } = []; + public List DiscoveredTestNames { get; internal set; } = []; public bool Success { get; internal set; } @@ -124,10 +124,10 @@ public void ReportFailedTest(string testNodeUid, string instanceId) }, static @this => @this.FailedTests++); } - public void DiscoverTest(string? displayName, string? uid, string? filePath, int? lineNumber) + public void DiscoverTest(DiscoveredTestInfo test) { DiscoveredTests++; - DiscoveredTestNames.Add(new(displayName, uid, filePath, lineNumber)); + DiscoveredTestNames.Add(test); } internal void NotifyHandshake(string instanceId) @@ -159,3 +159,19 @@ private int GetAttemptNumberFromInstanceId(string instanceId) return index + 1; } } + +/// +/// Rich information about a single discovered test node, as received over the 'dotnet test' IPC +/// protocol (DiscoveredTestMessage). Carries every field the wire contract provides so the +/// SDK can render both the human-readable and the machine-readable ('--list-tests json') output. +/// +internal sealed record DiscoveredTestInfo( + string? DisplayName, + string? Uid, + string? FilePath, + int? LineNumber, + string? Namespace, + string? TypeName, + string? MethodName, + string[] ParameterTypeFullNames, + (string Key, string Value)[] Traits); diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs index aa9986f3b29a..032e915458d7 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs @@ -220,10 +220,16 @@ internal void OnDiscoveredTestsReceived(DiscoveredTestMessages discoveredTestMes foreach (var test in discoveredTestMessages.DiscoveredMessages) { _output.TestDiscovered(_handshakeInfo.Value.ExecutionId, - ValidateRequiredMessageProperty(test.DisplayName, nameof(DiscoveredTestMessage.DisplayName), nameof(DiscoveredTestMessage)), - ValidateRequiredMessageProperty(test.Uid, nameof(DiscoveredTestMessage.Uid), nameof(DiscoveredTestMessage)), - test.FilePath, - test.LineNumber); + new DiscoveredTestInfo( + ValidateRequiredMessageProperty(test.DisplayName, nameof(DiscoveredTestMessage.DisplayName), nameof(DiscoveredTestMessage)), + ValidateRequiredMessageProperty(test.Uid, nameof(DiscoveredTestMessage.Uid), nameof(DiscoveredTestMessage)), + test.FilePath, + test.LineNumber, + test.Namespace, + test.TypeName, + test.MethodName, + test.ParameterTypeFullNames, + [.. test.Traits.Select(t => (t.Key, t.Value))])); } } diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndDiscoversTests.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndDiscoversTests.cs index 28c0976b4386..2bbc54184828 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndDiscoversTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndDiscoversTests.cs @@ -104,6 +104,43 @@ public void DiscoverMultipleTestProjectsWithTests_ShouldReturnExitCodeSuccess(st result.ExitCode.Should().Be(ExitCodes.Success); } + [DataRow(TestingConstants.Debug)] + [DataRow(TestingConstants.Release)] + [TestMethod] + public void DiscoverTestProjectWithTestsInJsonFormat_EmitsMachineReadableJson(string configuration) + { + TestAsset testInstance = TestAssetsManager.CopyTestAsset("TestProjectWithDiscoveredTests", Guid.NewGuid().ToString()) + .WithSource(); + + CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("--list-tests", "json", "-c", configuration); + + result.ExitCode.Should().Be(ExitCodes.Success); + + // The discovery JSON is emitted as a single object. Extract it from stdout (which may also + // contain restore/build output) and assert the versioned, container-grouped shape. + string stdout = result.StdOut!; + int start = stdout.IndexOf('{'); + int end = stdout.LastIndexOf('}'); + start.Should().BeGreaterThanOrEqualTo(0, $"expected a JSON document in the output. Full output:{Environment.NewLine}{stdout}"); + end.Should().BeGreaterThan(start); + + using var document = System.Text.Json.JsonDocument.Parse(stdout.Substring(start, end - start + 1)); + System.Text.Json.JsonElement root = document.RootElement; + + root.GetProperty("version").GetString().Should().Be("1.0"); + + System.Text.Json.JsonElement containers = root.GetProperty("testContainers"); + containers.GetArrayLength().Should().BeGreaterThanOrEqualTo(1); + + bool foundTest0 = containers.EnumerateArray() + .SelectMany(c => c.GetProperty("tests").EnumerateArray()) + .Any(t => t.GetProperty("uid").GetString() == "Test0"); + + foundTest0.Should().BeTrue("the discovered test 'Test0' should appear in the JSON output."); + } + [DataRow(TestingConstants.Debug)] [DataRow(TestingConstants.Release)] [TestMethod] diff --git a/test/dotnet.Tests/CommandTests/Test/TerminalTestReporterTests.cs b/test/dotnet.Tests/CommandTests/Test/TerminalTestReporterTests.cs index 28892d77c028..c20f64f9557d 100644 --- a/test/dotnet.Tests/CommandTests/Test/TerminalTestReporterTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TerminalTestReporterTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using System.Text.RegularExpressions; +using Microsoft.DotNet.Cli.Commands.Test; using Microsoft.DotNet.Cli.Commands.Test.Terminal; using Moq; @@ -264,6 +266,88 @@ public void AssemblyRunCompleted_WithLargeStandardOutput_TruncatesInSummary() output.Should().NotContain("help line 350"); } + /// + /// '--list-tests json' renders a machine-readable JSON document from the discovered-test data the + /// SDK already receives over the 'dotnet test' IPC protocol. The document is a versioned envelope + /// grouped by test container (assembly + TFM + architecture), preserving every field the wire + /// contract carries (uid, namespace, typeName, methodName, parameterTypeFullNames, traits, location). + /// See https://github.com/dotnet/sdk/issues/49754. + /// + [TestMethod] + public void TestExecutionCompleted_WhenDiscoveryJsonFormat_EmitsMachineReadableJson() + { + var capturingConsole = new CapturingConsole(); + + var options = new TerminalTestReporterOptions + { + AnsiMode = AnsiMode.NoAnsi, + ShowProgress = false, + ShowAssembly = false, + ShowAssemblyStartAndComplete = false, + ListTestsFormat = TestListFormat.Json, + }; + + using var reporter = new TerminalTestReporter(capturingConsole, options); + + reporter.TestExecutionStarted(DateTimeOffset.UtcNow, workerCount: 1, isDiscovery: true, isHelp: false, isRetry: false); + + const string assembly = "/repo/bin/Debug/net9.0/MyTests.dll"; + const string executionId = "exec-1"; + + reporter.AssemblyRunStarted(assembly, targetFramework: "net9.0", architecture: "x64", executionId, instanceId: "inst-1"); + + reporter.TestDiscovered(executionId, new DiscoveredTestInfo( + DisplayName: "MyMethod(x: 1)", + Uid: "uid-1", + FilePath: "/repo/MyTests.cs", + LineNumber: 42, + Namespace: "My.Ns", + TypeName: "MyClass", + MethodName: "MyMethod", + ParameterTypeFullNames: ["System.Int32"], + Traits: [("Category", "Fast")])); + + reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, exitCode: 0); + + string output = capturingConsole.GetOutput(); + int start = output.IndexOf('{'); + start.Should().BeGreaterThanOrEqualTo(0, "the discovery output should contain a JSON document"); + + using var document = JsonDocument.Parse(output.Substring(start)); + JsonElement root = document.RootElement; + + root.GetProperty("version").GetString().Should().Be("1.0"); + + JsonElement containers = root.GetProperty("testContainers"); + containers.GetArrayLength().Should().Be(1); + + JsonElement container = containers[0]; + container.GetProperty("assemblyPath").GetString().Should().Be(assembly); + container.GetProperty("targetFramework").GetString().Should().Be("net9.0"); + container.GetProperty("architecture").GetString().Should().Be("x64"); + + JsonElement tests = container.GetProperty("tests"); + tests.GetArrayLength().Should().Be(1); + + JsonElement test = tests[0]; + test.GetProperty("uid").GetString().Should().Be("uid-1"); + test.GetProperty("displayName").GetString().Should().Be("MyMethod(x: 1)"); + test.GetProperty("namespace").GetString().Should().Be("My.Ns"); + test.GetProperty("typeName").GetString().Should().Be("MyClass"); + test.GetProperty("methodName").GetString().Should().Be("MyMethod"); + test.GetProperty("filePath").GetString().Should().Be("/repo/MyTests.cs"); + test.GetProperty("lineNumber").GetInt32().Should().Be(42); + + JsonElement parameters = test.GetProperty("parameterTypeFullNames"); + parameters.GetArrayLength().Should().Be(1); + parameters[0].GetString().Should().Be("System.Int32"); + + JsonElement traits = test.GetProperty("traits"); + traits.GetArrayLength().Should().Be(1); + traits[0].GetProperty("key").GetString().Should().Be("Category"); + traits[0].GetProperty("value").GetString().Should().Be("Fast"); + } + private static void ReportTest(TerminalTestReporter reporter, string assembly, string executionId, string instanceId, string testUid, TestOutcome outcome) { reporter.TestCompleted( diff --git a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs index db899e967e43..55dd2d613e5b 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs @@ -59,7 +59,7 @@ public void OnTestProcessExited_WhenOnlyControllerHandshakeReceived_RoutesToHand TargetPath: targetPath, DotnetRootArchVariableName: null); - var testOptions = new TestOptions(IsHelp: false, IsDiscovery: false, EnvironmentVariables: new Dictionary()); + var testOptions = new TestOptions(IsHelp: false, IsDiscovery: false, ListTestsFormat: TestListFormat.Text, EnvironmentVariables: new Dictionary()); var handler = new TestApplicationHandler(reporter, module, testOptions); @@ -324,7 +324,7 @@ public void OnTestProcessExited_WhenSdkInHelpModeAndNoHandshakeReceived_DoesNotR TargetPath: TargetPath, DotnetRootArchVariableName: null); - var testOptions = new TestOptions(IsHelp: isHelp, IsDiscovery: isDiscovery, EnvironmentVariables: new Dictionary()); + var testOptions = new TestOptions(IsHelp: isHelp, IsDiscovery: isDiscovery, ListTestsFormat: TestListFormat.Text, EnvironmentVariables: new Dictionary()); return (new TestApplicationHandler(reporter, module, testOptions), reporter, capturingConsole); } diff --git a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs index 3314a0f8e21e..1e89249d980a 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs @@ -104,6 +104,44 @@ public void MTPCommandForwardsUseCurrentRuntimeOption(string optionAlias) $"{optionAlias} should be forwarded to MSBuild as UseCurrentRuntimeIdentifier=True so restore and build target the current runtime."); } + [TestMethod] + [DataRow("text")] + [DataRow("json")] + public void MTPCommandAcceptsListTestsFormatValue(string format) + { + var command = new TestCommandDefinition.MicrosoftTestingPlatform(); + var parseResult = command.Parse(["--list-tests", format]); + + parseResult.Errors.Should().BeEmpty(); + parseResult.HasOption(command.ListTestsOption).Should().BeTrue(); + parseResult.GetValue(command.ListTestsOption).Should().Be(format); + } + + [TestMethod] + public void MTPCommandAcceptsBareListTestsWithoutValue() + { + var command = new TestCommandDefinition.MicrosoftTestingPlatform(); + var parseResult = command.Parse(["--list-tests", "-c", "Release"]); + + // A bare '--list-tests' (followed by another option) has no value; discovery defaults to text. + parseResult.Errors.Should().BeEmpty(); + parseResult.HasOption(command.ListTestsOption).Should().BeTrue(); + parseResult.GetValue(command.ListTestsOption).Should().BeNull(); + } + + [TestMethod] + [DataRow("foo")] + [DataRow("JSON")] + [DataRow("TEXT")] + public void MTPCommandRejectsInvalidListTestsFormatValue(string format) + { + var command = new TestCommandDefinition.MicrosoftTestingPlatform(); + var parseResult = command.Parse(["--list-tests", format]); + + // Accepted values are constrained to the lowercase 'text'/'json' keys matching MTP. + parseResult.Errors.Should().NotBeEmpty(); + } + [TestMethod] public void DllDetectionShouldExcludeRunArgumentsAndGlobalProperties() { diff --git a/test/dotnet.Tests/CommandTests/Test/TestProgressStateTests.cs b/test/dotnet.Tests/CommandTests/Test/TestProgressStateTests.cs index 36504a3b33b5..2746432179c1 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestProgressStateTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestProgressStateTests.cs @@ -177,12 +177,12 @@ public void DiscoverTest_DisplayNameAndUid_AddsEntryAndIncrementsPassedTests(str stopwatch: stopwatchMock.Object, isDiscovery: true); - state.DiscoverTest(displayName, uid, filePath: null, lineNumber: null); + state.DiscoverTest(new DiscoveredTestInfo(displayName, uid, FilePath: null, LineNumber: null, Namespace: null, TypeName: null, MethodName: null, ParameterTypeFullNames: [], Traits: [])); state.DiscoveredTests.Should().Be(1); state.DiscoveredTestNames.Count.Should().Be(1); state.DiscoveredTestNames[0].DisplayName.Should().Be(displayName); - state.DiscoveredTestNames[0].UID.Should().Be(uid); + state.DiscoveredTestNames[0].Uid.Should().Be(uid); state.DiscoveredTestNames[0].FilePath.Should().BeNull(); state.DiscoveredTestNames[0].LineNumber.Should().BeNull(); } @@ -199,7 +199,7 @@ public void DiscoverTest_WithFilePathAndLineNumber_StoresLocation() stopwatch: stopwatchMock.Object, isDiscovery: true); - state.DiscoverTest("MyTest", "uid-1", filePath: "C:/repo/MyTests.cs", lineNumber: 42); + state.DiscoverTest(new DiscoveredTestInfo("MyTest", "uid-1", FilePath: "C:/repo/MyTests.cs", LineNumber: 42, Namespace: null, TypeName: null, MethodName: null, ParameterTypeFullNames: [], Traits: [])); state.DiscoveredTestNames.Count.Should().Be(1); state.DiscoveredTestNames[0].FilePath.Should().Be("C:/repo/MyTests.cs"); diff --git a/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt b/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt index 50fc339d565e..945493e4a4b3 100644 --- a/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt +++ b/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt @@ -39,7 +39,7 @@ Options: --no-ansi Disable ANSI output. [default: False] --no-progress Disable progress reporting. [default: False] --output Verbosity of test output. - --list-tests List the discovered tests instead of running the tests. + --list-tests List the discovered tests instead of running the tests. Optionally accepts a format: 'text' (default) for human-readable output or 'json' for machine-readable output. --no-launch-profile Do not attempt to use launchSettings.json or [app].run.json to configure the application. [default: False] --no-launch-profile-arguments Do not use arguments specified in launch profile to run the application. [default: False] --device The device identifier to use for running tests. @@ -84,4 +84,4 @@ Extension Options: --report-trx Enable generating TRX report --report-trx-filename The name of the generated TRX report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {arch} (process architecture), {time} (timestamp). - Example: MyReport_{tfm}.trx + Example: MyReport_{tfm}.trx \ No newline at end of file