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