diff --git a/docs/testconfig.schema.json b/docs/testconfig.schema.json
index ee9a240850..7ae9f8d76f 100644
--- a/docs/testconfig.schema.json
+++ b/docs/testconfig.schema.json
@@ -21,6 +21,24 @@
"additionalProperties": {
"type": ["string", "number", "boolean", "null"]
}
+ },
+ "commandLineOptionDefaults": {
+ "type": "object",
+ "description": "Passive argument defaults for registered command-line options. Keys omit the leading '--'. A default is used only when an enabled feature reads that option and no explicit value was supplied; it never enables the option or feature. Explicit command-line values and commandLineOptions entries take precedence.",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "type": ["string", "number", "boolean"]
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": ["string", "number", "boolean"]
+ }
+ }
+ ]
+ }
}
},
"additionalProperties": true,
diff --git a/docs/testconfig.schema.md b/docs/testconfig.schema.md
index e9e0cf3bf8..2131cda2d0 100644
--- a/docs/testconfig.schema.md
+++ b/docs/testconfig.schema.md
@@ -8,7 +8,8 @@ It covers:
- the MSTest section (`mstest:*` — `timeout`, `execution` (including `orderTestsByNameInClass`),
`parallelism`, `output`, `deployment`, `assemblyResolution`);
- the Microsoft.Testing.Platform host section (`platformOptions:*`);
-- the `environmentVariables` section.
+- the `environmentVariables` section;
+- passive command-line option argument defaults under `commandLineOptionDefaults`.
The schema is the source for IDE auto-completion and validation when authoring `testconfig.json`.
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs
index d7a369bc41..1b87aa6334 100644
--- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// 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.Extensions.TrxReport.Abstractions.Streaming;
@@ -163,7 +163,7 @@ public TrxReportEngine(
private string ResolveTrxOutputPath(string testAppModule)
{
string reportFileName;
- if (_commandLineOptionsService.TryGetOptionArgumentList(TrxReportGeneratorCommandLine.TrxReportFileNameOptionName, out string[]? fileName))
+ if (_commandLineOptionsService.TryGetOptionArgumentListOrDefault(TrxReportGeneratorCommandLine.TrxReportFileNameOptionName, out string[]? fileName))
{
// The argument may be a bare file name, a relative path or an absolute path. Placeholders
// are resolved first against the whole input. Only the leaf file name is sanitized for
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/FileSystem.cs b/src/Platform/Microsoft.Testing.Platform.MSBuild/FileSystem.cs
index b6d252fd6a..3a6174a7c4 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/FileSystem.cs
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/FileSystem.cs
@@ -20,5 +20,7 @@ public void CopyFile(string source, string destination)
public bool Exist(string path) => File.Exists(path);
+ public string ReadAllText(string path) => File.ReadAllText(path);
+
public void WriteAllText(string path, string? contents) => File.WriteAllText(path, contents);
}
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/IFileSystem.cs b/src/Platform/Microsoft.Testing.Platform.MSBuild/IFileSystem.cs
index 5ef7de2feb..dd4b8edf15 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/IFileSystem.cs
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/IFileSystem.cs
@@ -7,6 +7,8 @@ internal interface IFileSystem
{
bool Exist(string path);
+ string ReadAllText(string path);
+
void CopyFile(string source, string destination);
void WriteAllText(string path, string? contents);
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj b/src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj
index 50dd0df023..dfe74a128f 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/Microsoft.Testing.Platform.MSBuild.csproj
@@ -13,6 +13,8 @@
+
+
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.MSBuild/PACKAGE.md
index 834c0e6c6a..29cd8db99d 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/PACKAGE.md
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/PACKAGE.md
@@ -14,12 +14,32 @@ dotnet add package Microsoft.Testing.Platform.MSBuild
No manual API call is required. Referencing the package imports its MSBuild integration, which generates the test application entry point and copies `testconfig.json` to the output directory. Test framework packages normally reference this package transitively.
+Command-line option argument defaults can be authored directly in `testconfig.json`:
+
+```json
+{
+ "commandLineOptionDefaults": {
+ "report-trx-filename": "{asm}.trx"
+ }
+}
+```
+
+SDKs and shared build infrastructure can supply the same defaults through MSBuild:
+
+```xml
+
+
+
+```
+
+These defaults are passive: they do not enable `--report-trx` or any other feature. An explicit command-line value or active `commandLineOptions` entry takes precedence. If both `testconfig.json` and MSBuild define the same default, the value in `testconfig.json` wins.
+
## About
This package provides:
- **Entry-point generation**: generates the required entry point for Microsoft.Testing.Platform test projects
-- **Configuration file support**: copies `testconfig.json` from the project into the output directory as `$(AssemblyName).testconfig.json`
+- **Configuration file support**: generates `$(AssemblyName).testconfig.json` from the project configuration and any `TestingPlatformCommandLineOptionDefault` items
- **`dotnet test` compatibility**: enables running MTP-based test projects through the VSTest-based `dotnet test` command on .NET SDKs
This package is typically **not referenced directly**. Instead, test framework packages (such as [MSTest](https://www.nuget.org/packages/MSTest)) reference it automatically.
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/Tasks/ConfigurationFileTask.cs b/src/Platform/Microsoft.Testing.Platform.MSBuild/Tasks/ConfigurationFileTask.cs
index c426fe70b2..7248a8d63f 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/Tasks/ConfigurationFileTask.cs
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/Tasks/ConfigurationFileTask.cs
@@ -4,10 +4,16 @@
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
+#if NETCOREAPP
+using System.Text.Json;
+#else
+using Jsonite;
+#endif
+
namespace Microsoft.Testing.Platform.MSBuild;
///
-/// A task that copies the Microsoft Testing Platform configuration file to the output directory.
+/// A task that creates the Microsoft Testing Platform configuration file in the output directory.
///
// Took inspiration from https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/GenerateRuntimeConfigurationFiles.cs
public sealed class ConfigurationFileTask : Build.Utilities.Task
@@ -50,9 +56,16 @@ public ConfigurationFileTask()
[Required]
public required ITaskItem OutputPath { get; set; }
+ ///
+ /// Gets or sets passive command-line option defaults to merge into the generated configuration file.
+ /// Item identities are option names and the Value metadata contains one argument value.
+ /// Repeating an identity produces a JSON array.
+ ///
+ public ITaskItem[]? TestingPlatformCommandLineOptionDefault { get; set; }
+
///
/// Gets or sets the final Microsoft Testing Platform configuration file. It stays when
- /// no configuration file was found, in which case the task produces no output item.
+ /// neither a source configuration file nor option defaults were provided, in which case the task produces no output item.
///
[Output]
public ITaskItem? FinalTestingPlatformConfigurationFile { get; set; }
@@ -61,7 +74,8 @@ public ConfigurationFileTask()
public override bool Execute()
{
Log.LogMessage(MessageImportance.Normal, $"Microsoft Testing Platform configuration file: '{TestingPlatformConfigurationFileSource.ItemSpec}'");
- if (!_fileSystem.Exist(TestingPlatformConfigurationFileSource.ItemSpec))
+ bool sourceExists = _fileSystem.Exist(TestingPlatformConfigurationFileSource.ItemSpec);
+ if (!sourceExists && TestingPlatformCommandLineOptionDefault is not { Length: > 0 })
{
Log.LogMessage(MessageImportance.Normal, "Microsoft Testing Platform configuration file not found");
return true;
@@ -77,11 +91,331 @@ public override bool Execute()
string finalFileName = Path.Combine(finalPath, $"{AssemblyName.ItemSpec}.{ConfigurationFileNameSuffix}");
Log.LogMessage(MessageImportance.Normal, $"Final configuration file path : '{finalFileName}'");
- Log.LogMessage(MessageImportance.Normal, $"Configuration file found: '{TestingPlatformConfigurationFileSource.ItemSpec}'");
- _fileSystem.CopyFile(TestingPlatformConfigurationFileSource.ItemSpec, finalFileName);
+ if (TestingPlatformCommandLineOptionDefault is not { Length: > 0 } optionDefaults)
+ {
+ Log.LogMessage(MessageImportance.Normal, $"Configuration file found: '{TestingPlatformConfigurationFileSource.ItemSpec}'");
+ _fileSystem.CopyFile(TestingPlatformConfigurationFileSource.ItemSpec, finalFileName);
+ }
+ else
+ {
+ if (!TryCreateMergedConfiguration(
+ sourceExists ? _fileSystem.ReadAllText(TestingPlatformConfigurationFileSource.ItemSpec) : null,
+ optionDefaults,
+ out string? mergedConfiguration))
+ {
+ return false;
+ }
+
+ _fileSystem.WriteAllText(finalFileName, mergedConfiguration);
+ }
+
FinalTestingPlatformConfigurationFile = new TaskItem(finalFileName);
Log.LogMessage(MessageImportance.Normal, "Microsoft Testing Platform configuration file written");
return true;
}
+
+ private bool TryCreateMergedConfiguration(
+ string? source,
+ ITaskItem[] optionDefaults,
+ [NotNullWhen(true)] out string? mergedConfiguration)
+ {
+ var valuesByOption = new Dictionary Values)>(StringComparer.OrdinalIgnoreCase);
+ foreach (ITaskItem item in optionDefaults)
+ {
+ string optionName = item.ItemSpec.Trim();
+ if (RoslynString.IsNullOrEmpty(optionName))
+ {
+ Log.LogError("TestingPlatformCommandLineOptionDefault items must have a non-empty option name.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ if (optionName[0] == '-')
+ {
+ Log.LogError($"TestingPlatformCommandLineOptionDefault item '{optionName}' must use the option name without leading hyphens.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ if (!valuesByOption.TryGetValue(optionName, out (string Name, List Values) entry))
+ {
+ entry = (optionName, []);
+ }
+
+ entry.Values.Add(item.GetMetadata("Value"));
+ valuesByOption[optionName] = entry;
+ }
+
+#if NETCOREAPP
+ return TryCreateMergedConfigurationNet(source, valuesByOption.Values, out mergedConfiguration);
+#else
+ return TryCreateMergedConfigurationNetStandard(source, valuesByOption.Values, out mergedConfiguration);
+#endif
+ }
+
+#if NETCOREAPP
+ private bool TryCreateMergedConfigurationNet(
+ string? source,
+ IEnumerable<(string Name, List Values)> optionDefaults,
+ [NotNullWhen(true)] out string? mergedConfiguration)
+ {
+ try
+ {
+ if (source is not null)
+ {
+ using var document = JsonDocument.Parse(
+ source,
+ new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ Log.LogError("The top-level element in the Microsoft Testing Platform configuration file must be a JSON object.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ return TryWriteMergedConfiguration(document.RootElement, optionDefaults, out mergedConfiguration);
+ }
+
+ return TryWriteMergedConfiguration(root: null, optionDefaults, out mergedConfiguration);
+ }
+ catch (JsonException ex)
+ {
+ Log.LogError($"Failed to parse Microsoft Testing Platform configuration file '{TestingPlatformConfigurationFileSource.ItemSpec}': {ex.Message}");
+ mergedConfiguration = null;
+ return false;
+ }
+ }
+
+ private bool TryWriteMergedConfiguration(
+ JsonElement? root,
+ IEnumerable<(string Name, List Values)> optionDefaults,
+ [NotNullWhen(true)] out string? mergedConfiguration)
+ {
+ const string SectionName = "commandLineOptionDefaults";
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
+ {
+ writer.WriteStartObject();
+ bool wroteDefaults = false;
+ var rootPropertyNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (root is JsonElement rootElement)
+ {
+ foreach (JsonProperty property in rootElement.EnumerateObject())
+ {
+ if (!rootPropertyNames.Add(property.Name))
+ {
+ Log.LogError($"The Microsoft Testing Platform configuration file contains duplicate keys that differ only by casing: '{property.Name}'.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ if (!string.Equals(property.Name, SectionName, StringComparison.OrdinalIgnoreCase))
+ {
+ property.WriteTo(writer);
+ continue;
+ }
+
+ if (property.Value.ValueKind != JsonValueKind.Object)
+ {
+ Log.LogError($"The '{SectionName}' section in the Microsoft Testing Platform configuration file must be a JSON object.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ writer.WritePropertyName(property.Name);
+ writer.WriteStartObject();
+ var configuredOptionNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (JsonProperty optionDefault in property.Value.EnumerateObject())
+ {
+ if (!configuredOptionNames.Add(optionDefault.Name))
+ {
+ Log.LogError($"The '{SectionName}' section contains duplicate option names that differ only by casing: '{optionDefault.Name}'.");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ optionDefault.WriteTo(writer);
+ }
+
+ WriteOptionDefaults(writer, optionDefaults, configuredOptionNames);
+ writer.WriteEndObject();
+ wroteDefaults = true;
+ }
+ }
+
+ if (!wroteDefaults)
+ {
+ writer.WritePropertyName(SectionName);
+ writer.WriteStartObject();
+ WriteOptionDefaults(writer, optionDefaults, []);
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndObject();
+ }
+
+ mergedConfiguration = Encoding.UTF8.GetString(stream.ToArray()) + Environment.NewLine;
+ return true;
+ }
+
+ private static void WriteOptionDefaults(
+ Utf8JsonWriter writer,
+ IEnumerable<(string Name, List Values)> optionDefaults,
+ HashSet configuredOptionNames)
+ {
+ foreach ((string optionName, List values) in optionDefaults)
+ {
+ if (configuredOptionNames.Contains(optionName))
+ {
+ continue;
+ }
+
+ writer.WritePropertyName(optionName);
+ if (values.Count == 1)
+ {
+ writer.WriteStringValue(values[0]);
+ }
+ else
+ {
+ writer.WriteStartArray();
+ foreach (string value in values)
+ {
+ writer.WriteStringValue(value);
+ }
+
+ writer.WriteEndArray();
+ }
+ }
+ }
+#else
+ private bool TryCreateMergedConfigurationNetStandard(
+ string? source,
+ IEnumerable<(string Name, List Values)> optionDefaults,
+ [NotNullWhen(true)] out string? mergedConfiguration)
+ {
+ JsonObject configuration;
+ try
+ {
+ if (source is null)
+ {
+ configuration = [];
+ }
+ else if (Json.Deserialize(
+ source,
+ new JsonSettings
+ {
+ AllowComments = true,
+ AllowTrailingCommas = true,
+ }) is JsonObject parsedObject)
+ {
+ configuration = parsedObject;
+ }
+ else
+ {
+ Log.LogError("The top-level element in the Microsoft Testing Platform configuration file must be a JSON object.");
+ mergedConfiguration = null;
+ return false;
+ }
+ }
+ catch (JsonException ex)
+ {
+ Log.LogError($"Failed to parse Microsoft Testing Platform configuration file '{TestingPlatformConfigurationFileSource.ItemSpec}': {ex.Message}");
+ mergedConfiguration = null;
+ return false;
+ }
+
+ if (!TryGetDefaultsObject(configuration, out JsonObject? defaultsObject))
+ {
+ mergedConfiguration = null;
+ return false;
+ }
+
+ foreach ((string optionName, List values) in optionDefaults)
+ {
+ if (!TryGetCaseInsensitiveValue(defaultsObject, optionName, out bool hasConfiguredDefault, out _))
+ {
+ mergedConfiguration = null;
+ return false;
+ }
+
+ if (hasConfiguredDefault)
+ {
+ continue;
+ }
+
+ if (values.Count == 1)
+ {
+ defaultsObject.Add(optionName, values[0]);
+ }
+ else
+ {
+ var array = new JsonArray { Capacity = values.Count };
+ array.AddRange(values);
+ defaultsObject.Add(optionName, array);
+ }
+ }
+
+ mergedConfiguration = Json.Serialize(configuration, new JsonSettings { Indent = true }) + Environment.NewLine;
+ return true;
+ }
+
+ private bool TryGetDefaultsObject(JsonObject configuration, [NotNullWhen(true)] out JsonObject? defaultsObject)
+ {
+ const string SectionName = "commandLineOptionDefaults";
+ if (!TryGetCaseInsensitiveValue(configuration, SectionName, out bool hasSection, out object? sectionValue))
+ {
+ defaultsObject = null;
+ return false;
+ }
+
+ if (!hasSection)
+ {
+ defaultsObject = [];
+ configuration.Add(SectionName, defaultsObject);
+ return true;
+ }
+
+ if (sectionValue is JsonObject sectionObject)
+ {
+ defaultsObject = sectionObject;
+ return true;
+ }
+
+ Log.LogError($"The '{SectionName}' section in the Microsoft Testing Platform configuration file must be a JSON object.");
+ defaultsObject = null;
+ return false;
+ }
+
+ private bool TryGetCaseInsensitiveValue(JsonObject jsonObject, string requestedKey, out bool found, out object? value)
+ {
+ found = false;
+ value = null;
+ string? actualKey = null;
+ foreach (KeyValuePair entry in jsonObject)
+ {
+ if (!string.Equals(entry.Key, requestedKey, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (actualKey is not null)
+ {
+ Log.LogError($"The Microsoft Testing Platform configuration file contains duplicate keys that differ only by casing: '{actualKey}' and '{entry.Key}'.");
+ return false;
+ }
+
+ actualKey = entry.Key;
+ found = true;
+ value = entry.Value;
+ }
+
+ return true;
+ }
+#endif
}
diff --git a/src/Platform/Microsoft.Testing.Platform.MSBuild/buildMultiTargeting/Microsoft.Testing.Platform.MSBuild.targets b/src/Platform/Microsoft.Testing.Platform.MSBuild/buildMultiTargeting/Microsoft.Testing.Platform.MSBuild.targets
index a602da8122..c85f2c4bb8 100644
--- a/src/Platform/Microsoft.Testing.Platform.MSBuild/buildMultiTargeting/Microsoft.Testing.Platform.MSBuild.targets
+++ b/src/Platform/Microsoft.Testing.Platform.MSBuild/buildMultiTargeting/Microsoft.Testing.Platform.MSBuild.targets
@@ -13,8 +13,9 @@
===========================
The configuration file is generated by the GenerateTestingPlatformConfigurationFile target.
- We copy the file called testconfig.json from the project directory to the output directory.
- The file is copied only if it does not exist in the output directory or if it has changed.
+ We copy the file called testconfig.json from the project directory to the output directory and
+ merge any @(TestingPlatformCommandLineOptionDefault) items into commandLineOptionDefaults.
+ A configuration file is generated from the items when no source testconfig.json exists.
The file is copied to the output directory before the CopyFilesToOutputDirectory target is executed.
-->
@@ -28,6 +29,11 @@
<_TestingPlatformConfigurationFileName>$(AssemblyName).testconfig.json
<_TestingPlatformConfigurationFile>$([System.IO.Path]::Combine($(MSBuildProjectDirectory),$(OutputPath),$(_TestingPlatformConfigurationFileName)))
+
+ <_TestingPlatformConfigurationFileInput Include="$(MSBuildAllProjects)" />
+ <_TestingPlatformConfigurationFileInput Include="$(_TestingPlatformConfigurationFileSourcePath)"
+ Condition=" Exists('$(_TestingPlatformConfigurationFileSourcePath)') " />
+
@@ -39,17 +45,41 @@
+ DependsOnTargets="_CalculateGenerateTestingPlatformConfigurationFile;_GenerateTestingPlatformConfigurationFileInputCache;_GenerateTestingPlatformConfigurationFileCore" />
+
+
+
+ <_GenerateTestingPlatformConfigurationFileInputCachePath>$(IntermediateOutputPath)$(MSBuildProjectName).gentestingplatformconfigurationinputcache.cache
+ <_GenerateTestingPlatformConfigurationFileInputCachePath>$([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(_GenerateTestingPlatformConfigurationFileInputCachePath)))
+
+
+ <_GenerateTestingPlatformConfigurationFileInputsToHash Include="@(TestingPlatformCommandLineOptionDefault->'%(Identity)=%(Value)')" />
+
+
+
+
+
+
+
+
+
+
+ Condition=" '$(GenerateTestingPlatformConfigurationFile)' == 'true' And (Exists('$(_TestingPlatformConfigurationFileSourcePath)') Or '@(TestingPlatformCommandLineOptionDefault)' != '') " >
+ TestingPlatformConfigurationFileSource="$(_TestingPlatformConfigurationFileSourcePath)"
+ TestingPlatformCommandLineOptionDefault="@(TestingPlatformCommandLineOptionDefault)" >
@@ -91,14 +121,14 @@
which would cause the Delete task to run and remove user-provided files.
-->
+ Condition=" ('$(GenerateTestingPlatformConfigurationFile)' != 'true' Or (!Exists('$(_TestingPlatformConfigurationFileSourcePath)') And '@(TestingPlatformCommandLineOptionDefault)' == '')) And '$(_TestingPlatformConfigurationFileIsUserOwned)' != 'true' " />
+ Condition=" ('$(GenerateTestingPlatformConfigurationFile)' != 'true' Or (!Exists('$(_TestingPlatformConfigurationFileSourcePath)') And '@(TestingPlatformCommandLineOptionDefault)' == '')) And '$(PublishDir)' != '' And '$(_TestingPlatformConfigurationFileIsUserOwned)' != 'true' " />
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineHandler.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineHandler.cs
index cdb83983de..5ef338349c 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineHandler.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineHandler.cs
@@ -13,7 +13,7 @@
namespace Microsoft.Testing.Platform.CommandLine;
-internal sealed class CommandLineHandler : ICommandLineHandler, ICommandLineOptions, IOutputDeviceDataProducer
+internal sealed class CommandLineHandler : ICommandLineHandler, ICommandLineOptions, ICommandLineOptionsWithDefaults, IOutputDeviceDataProducer
{
private static readonly TextOutputDeviceData EmptyText = new(string.Empty);
@@ -242,6 +242,11 @@ public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out
? _configuration.TryGetCommandLineOptionArguments(optionName, out arguments)
: ParseResult.TryGetOptionArgumentList(optionName, out arguments);
+ bool ICommandLineOptionsWithDefaults.TryGetOptionArgumentListOrDefault(string optionName, [NotNullWhen(true)] out string[]? arguments)
+ => _configuration is not null
+ ? _configuration.TryGetCommandLineOptionArgumentsOrDefault(optionName, out arguments)
+ : ParseResult.TryGetOptionArgumentList(optionName, out arguments);
+
public Task IsEnabledAsync() => Task.FromResult(false);
public bool IsHelpInvoked() => IsOptionSet(PlatformCommandLineProvider.HelpOptionKey) || IsOptionSet(PlatformCommandLineProvider.HelpOptionQuestionMark);
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsProxy.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsProxy.cs
index e2f1b86a16..2091e0e78c 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsProxy.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsProxy.cs
@@ -3,7 +3,7 @@
namespace Microsoft.Testing.Platform.CommandLine;
-internal sealed class CommandLineOptionsProxy : ICommandLineOptions
+internal sealed class CommandLineOptionsProxy : ICommandLineOptions, ICommandLineOptionsWithDefaults
{
private ICommandLineOptions? _commandLineOptions;
@@ -16,6 +16,13 @@ public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out
? throw new InvalidOperationException(Resources.PlatformResources.CommandLineOptionsNotReady)
: _commandLineOptions.TryGetOptionArgumentList(optionName, out arguments);
+ bool ICommandLineOptionsWithDefaults.TryGetOptionArgumentListOrDefault(string optionName, [NotNullWhen(true)] out string[]? arguments)
+ => _commandLineOptions is null
+ ? throw new InvalidOperationException(Resources.PlatformResources.CommandLineOptionsNotReady)
+ : _commandLineOptions is ICommandLineOptionsWithDefaults commandLineOptionsWithDefaults
+ ? commandLineOptionsWithDefaults.TryGetOptionArgumentListOrDefault(optionName, out arguments)
+ : _commandLineOptions.TryGetOptionArgumentList(optionName, out arguments);
+
public void SetCommandLineOptions(ICommandLineOptions commandLineOptions)
=> _commandLineOptions = commandLineOptions;
}
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArgumentAndConfigurationValidation.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArgumentAndConfigurationValidation.cs
index c1429dedd7..38de65d02a 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArgumentAndConfigurationValidation.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArgumentAndConfigurationValidation.cs
@@ -13,6 +13,7 @@ internal static partial class CommandLineOptionsValidator
private static async Task ValidateOptionsArgumentsAsync(
CommandLineParseResult parseResult,
IReadOnlyList? jsonCommandLineOptions,
+ IReadOnlyList? jsonCommandLineOptionDefaults,
Dictionary providerAndOptionByOptionName)
{
if (parseResult is null)
@@ -35,13 +36,21 @@ record => record.Name,
}
}
- // Apply the per-option argument validators to JSON-sourced entries as well. Skip disabled
- // entries (nothing to validate) and entries that the prior arity pass already flagged
- // (calling a provider's validator with too-few/too-many arguments may produce confusing
- // secondary errors or, worse, index out of bounds inside the validator itself).
- if (jsonCommandLineOptions is { Count: > 0 })
+ await ValidateJsonEntriesAsync(jsonCommandLineOptions, PlatformConfigurationConstants.CommandLineOptionsSectionName).ConfigureAwait(false);
+ await ValidateJsonEntriesAsync(jsonCommandLineOptionDefaults, PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName).ConfigureAwait(false);
+
+ return stringBuilder?.Length > 0
+ ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
+ : ValidationResult.Valid();
+
+ async Task ValidateJsonEntriesAsync(IReadOnlyList? entries, string sectionName)
{
- foreach (JsonCommandLineOptionEntry entry in jsonCommandLineOptions)
+ if (entries is null)
+ {
+ return;
+ }
+
+ foreach (JsonCommandLineOptionEntry entry in entries)
{
if (entry.IsDisabled)
{
@@ -64,14 +73,10 @@ record => record.Name,
{
stringBuilder ??= new();
string innerError = string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineInvalidArgumentsForOption, entry.OptionName, result.ErrorMessage);
- stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, innerError));
+ stringBuilder.AppendLine(FormatJsonValidationError(sectionName, innerError));
}
}
}
-
- return stringBuilder?.Length > 0
- ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
- : ValidationResult.Valid();
}
private static async Task ValidateConfigurationAsync(
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArityValidation.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArityValidation.cs
index 2ad3a837f8..832e636334 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArityValidation.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.ArityValidation.cs
@@ -13,6 +13,7 @@ internal static partial class CommandLineOptionsValidator
private static ValidationResult ValidateOptionsArgumentArity(
CommandLineParseResult parseResult,
IReadOnlyList? jsonCommandLineOptions,
+ IReadOnlyList? jsonCommandLineOptionDefaults,
Dictionary providerAndOptionByOptionName)
{
StringBuilder? stringBuilder = null;
@@ -28,15 +29,24 @@ private static ValidationResult ValidateOptionsArgumentArity(
string optionName = groupedOptions.Key;
(ICommandLineOptionsProvider provider, CommandLineOption option) = providerAndOptionByOptionName[optionName];
- AppendArityErrorIfNeeded(stringBuilder: ref stringBuilder, arity, optionName, provider, option, jsonPrefix: false);
+ AppendArityErrorIfNeeded(stringBuilder: ref stringBuilder, arity, optionName, provider, option, jsonSectionName: null);
}
- // Apply the same arity rules to entries sourced from testconfig.json. We skip
- // explicit disable entries ("foo": false) because they convey "the option is not set" —
- // there are no arguments to validate. Unknown options were rejected earlier.
- if (jsonCommandLineOptions is { Count: > 0 })
+ ValidateJsonEntries(jsonCommandLineOptions, PlatformConfigurationConstants.CommandLineOptionsSectionName);
+ ValidateJsonEntries(jsonCommandLineOptionDefaults, PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName);
+
+ return stringBuilder?.Length > 0
+ ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
+ : ValidationResult.Valid();
+
+ void ValidateJsonEntries(IReadOnlyList? entries, string sectionName)
{
- foreach (JsonCommandLineOptionEntry entry in jsonCommandLineOptions)
+ if (entries is null)
+ {
+ return;
+ }
+
+ foreach (JsonCommandLineOptionEntry entry in entries)
{
if (entry.IsDisabled)
{
@@ -48,13 +58,9 @@ private static ValidationResult ValidateOptionsArgumentArity(
continue;
}
- AppendArityErrorIfNeeded(stringBuilder: ref stringBuilder, entry.Arguments.Count, entry.OptionName, match.Provider, match.Option, jsonPrefix: true);
+ AppendArityErrorIfNeeded(stringBuilder: ref stringBuilder, entry.Arguments.Count, entry.OptionName, match.Provider, match.Option, sectionName);
}
}
-
- return stringBuilder?.Length > 0
- ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
- : ValidationResult.Valid();
}
private static void AppendArityErrorIfNeeded(
@@ -63,7 +69,7 @@ private static void AppendArityErrorIfNeeded(
string optionName,
ICommandLineOptionsProvider provider,
CommandLineOption option,
- bool jsonPrefix)
+ string? jsonSectionName)
{
string? message = null;
if (arity > option.Arity.Max && option.Arity.Max == 0)
@@ -85,8 +91,8 @@ private static void AppendArityErrorIfNeeded(
}
stringBuilder ??= new();
- stringBuilder.AppendLine(jsonPrefix
- ? string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, message)
+ stringBuilder.AppendLine(jsonSectionName is not null
+ ? FormatJsonValidationError(jsonSectionName, message)
: message);
}
}
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs
index 710a50e31d..8688b0c9f9 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs
@@ -112,6 +112,7 @@ internal static partial class CommandLineOptionsValidator
private static ValidationResult ValidateNoUnknownOptions(
CommandLineParseResult parseResult,
IReadOnlyList? jsonCommandLineOptions,
+ IReadOnlyList? jsonCommandLineOptionDefaults,
Dictionary> extensionOptionsByProvider,
Dictionary> systemOptionsByProvider)
{
@@ -135,13 +136,26 @@ private static ValidationResult ValidateNoUnknownOptions(
}
}
- // Also surface unknown entries under the testconfig.json "commandLineOptions" section.
- // We intentionally validate even when the CLI provides a matching option of the same name
- // (which would shadow the JSON value at lookup time): a JSON typo silently overridden by
- // the CLI is still a typo that the user wants to know about.
- if (jsonCommandLineOptions is { Count: > 0 })
+ ValidateJsonEntries(jsonCommandLineOptions, PlatformConfigurationConstants.CommandLineOptionsSectionName);
+ ValidateJsonEntries(jsonCommandLineOptionDefaults, PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName);
+
+ if (stringBuilder?.Length > 0)
+ {
+ stringBuilder.AppendLine(PlatformResources.CommandLineUnknownOptionsHint);
+ }
+
+ return stringBuilder?.Length > 0
+ ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
+ : ValidationResult.Valid();
+
+ void ValidateJsonEntries(IReadOnlyList? entries, string sectionName)
{
- foreach (JsonCommandLineOptionEntry entry in jsonCommandLineOptions)
+ if (entries is null)
+ {
+ return;
+ }
+
+ foreach (JsonCommandLineOptionEntry entry in entries)
{
if (!validOptionNames.Contains(entry.OptionName))
{
@@ -149,19 +163,10 @@ private static ValidationResult ValidateNoUnknownOptions(
StringBuilder innerErrorBuilder = new();
AppendUnknownOptionError(innerErrorBuilder, entry.OptionName, entry.Arguments, validOptionNames, visibleOptionNames, includeKnownExtensionOptions);
string innerError = innerErrorBuilder.ToTrimmedString();
- stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, innerError));
+ stringBuilder.AppendLine(FormatJsonValidationError(sectionName, innerError));
}
}
}
-
- if (stringBuilder?.Length > 0)
- {
- stringBuilder.AppendLine(PlatformResources.CommandLineUnknownOptionsHint);
- }
-
- return stringBuilder?.Length > 0
- ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
- : ValidationResult.Valid();
}
private static void CollectOptionNames(
@@ -470,28 +475,45 @@ private static int CalculateEditDistance(string source, string target)
}
private static ValidationResult ValidateNoBootstrapOnlyOptionsInJson(
- IReadOnlyList? jsonCommandLineOptions)
+ IReadOnlyList? jsonCommandLineOptions,
+ IReadOnlyList? jsonCommandLineOptionDefaults)
{
- if (jsonCommandLineOptions is not { Count: > 0 })
+ if (jsonCommandLineOptions is not { Count: > 0 }
+ && jsonCommandLineOptionDefaults is not { Count: > 0 })
{
return ValidationResult.Valid();
}
StringBuilder? stringBuilder = null;
- foreach (JsonCommandLineOptionEntry entry in jsonCommandLineOptions)
+ ValidateEntries(jsonCommandLineOptions, PlatformConfigurationConstants.CommandLineOptionsSectionName);
+ ValidateEntries(jsonCommandLineOptionDefaults, PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName);
+
+ return stringBuilder?.Length > 0
+ ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
+ : ValidationResult.Valid();
+
+ void ValidateEntries(IReadOnlyList? entries, string sectionName)
{
- if (!BootstrapOnlyOptions.Contains(entry.OptionName))
+ if (entries is null)
{
- continue;
+ return;
}
- stringBuilder ??= new();
- string innerError = string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionIsBootstrapOnlyErrorMessage, entry.OptionName);
- stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, innerError));
- }
+ foreach (JsonCommandLineOptionEntry entry in entries)
+ {
+ if (!BootstrapOnlyOptions.Contains(entry.OptionName))
+ {
+ continue;
+ }
- return stringBuilder?.Length > 0
- ? ValidationResult.Invalid(stringBuilder.ToTrimmedString())
- : ValidationResult.Valid();
+ stringBuilder ??= new();
+ string innerError = string.Format(
+ CultureInfo.InvariantCulture,
+ PlatformResources.JsonCommandLineOptionIsBootstrapOnlyErrorMessage,
+ entry.OptionName,
+ sectionName);
+ stringBuilder.AppendLine(FormatJsonValidationError(sectionName, innerError));
+ }
+ }
}
}
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.cs
index 482fc75bca..e13dc112ad 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.cs
@@ -38,7 +38,8 @@ public static async Task ValidateAsync(
IEnumerable systemCommandLineOptionsProviders,
IEnumerable extensionCommandLineOptionsProviders,
ICommandLineOptions commandLineOptions,
- IReadOnlyList? jsonCommandLineOptions = null)
+ IReadOnlyList? jsonCommandLineOptions = null,
+ IReadOnlyList? jsonCommandLineOptionDefaults = null)
{
if (commandLineParseResult.HasError)
{
@@ -75,12 +76,12 @@ public static async Task ValidateAsync(
return result3;
}
- if (ValidateNoUnknownOptions(commandLineParseResult, jsonCommandLineOptions, extensionOptionsByProvider, systemOptionsByProvider) is { IsValid: false } result4)
+ if (ValidateNoUnknownOptions(commandLineParseResult, jsonCommandLineOptions, jsonCommandLineOptionDefaults, extensionOptionsByProvider, systemOptionsByProvider) is { IsValid: false } result4)
{
return AddCommandLine(commandLineParseResult, result4);
}
- if (ValidateNoBootstrapOnlyOptionsInJson(jsonCommandLineOptions) is { IsValid: false } resultBootstrap)
+ if (ValidateNoBootstrapOnlyOptionsInJson(jsonCommandLineOptions, jsonCommandLineOptionDefaults) is { IsValid: false } resultBootstrap)
{
return AddCommandLine(commandLineParseResult, resultBootstrap);
}
@@ -94,12 +95,12 @@ public static async Task ValidateAsync(
.SelectMany(tuple => tuple.Value.Select(option => (provider: tuple.Key, option)))
.ToDictionary(tuple => tuple.option.Name, StringComparer.OrdinalIgnoreCase);
- if (ValidateOptionsArgumentArity(commandLineParseResult, jsonCommandLineOptions, providerAndOptionByOptionName) is { IsValid: false } result5)
+ if (ValidateOptionsArgumentArity(commandLineParseResult, jsonCommandLineOptions, jsonCommandLineOptionDefaults, providerAndOptionByOptionName) is { IsValid: false } result5)
{
return AddCommandLine(commandLineParseResult, result5);
}
- if (await ValidateOptionsArgumentsAsync(commandLineParseResult, jsonCommandLineOptions, providerAndOptionByOptionName).ConfigureAwait(false) is { IsValid: false } result6)
+ if (await ValidateOptionsArgumentsAsync(commandLineParseResult, jsonCommandLineOptions, jsonCommandLineOptionDefaults, providerAndOptionByOptionName).ConfigureAwait(false) is { IsValid: false } result6)
{
return AddCommandLine(commandLineParseResult, result6);
}
@@ -108,6 +109,11 @@ public static async Task ValidateAsync(
return await ValidateConfigurationAsync(extensionOptionsByProvider.Keys, systemOptionsByProvider.Keys, commandLineOptions).ConfigureAwait(false);
}
+ private static string FormatJsonValidationError(string sectionName, string error)
+ => sectionName == PlatformConfigurationConstants.CommandLineOptionsSectionName
+ ? string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, error)
+ : string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionDefaultsValidationErrorPrefix, error);
+
internal static ValidationResult ValidateToolProviders(
IEnumerable toolProviders,
IEnumerable systemProviders)
diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs
index bfb1e2455b..55a15701f3 100644
--- a/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs
+++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs
@@ -23,3 +23,40 @@ public interface ICommandLineOptions
/// True if the argument list is found; otherwise, false.
bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out string[]? arguments);
}
+
+///
+/// Provides extension methods for command-line options.
+///
+public static class CommandLineOptionsExtensions
+{
+ ///
+ /// Tries to get the explicit argument list for an option, falling back to a passive default
+ /// from commandLineOptionDefaults in testconfig.json.
+ ///
+ /// The command-line options.
+ /// The name of the option.
+ /// The explicit or default argument list, if found.
+ /// if an explicit value or configured default was found; otherwise, .
+ ///
+ /// A configured default does not make return
+ /// . Extensions should use this method only after determining that the feature
+ /// which owns the option is enabled.
+ ///
+ public static bool TryGetOptionArgumentListOrDefault(
+ this ICommandLineOptions commandLineOptions,
+ string optionName,
+ [NotNullWhen(true)] out string[]? arguments)
+ {
+ _ = commandLineOptions ?? throw new ArgumentNullException(nameof(commandLineOptions));
+ _ = optionName ?? throw new ArgumentNullException(nameof(optionName));
+
+ return commandLineOptions is ICommandLineOptionsWithDefaults commandLineOptionsWithDefaults
+ ? commandLineOptionsWithDefaults.TryGetOptionArgumentListOrDefault(optionName, out arguments)
+ : commandLineOptions.TryGetOptionArgumentList(optionName, out arguments);
+ }
+}
+
+internal interface ICommandLineOptionsWithDefaults
+{
+ bool TryGetOptionArgumentListOrDefault(string optionName, [NotNullWhen(true)] out string[]? arguments);
+}
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/AggregatedConfiguration.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/AggregatedConfiguration.cs
index 336829d01e..1238bec528 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/AggregatedConfiguration.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/AggregatedConfiguration.cs
@@ -223,6 +223,44 @@ internal bool TryGetCommandLineOptionFromProviders(string optionName, out bool i
return false;
}
+ ///
+ /// Resolves a passive command-line option default without changing the option's active state.
+ ///
+ internal bool TryGetCommandLineOptionDefaultFromProviders(string optionName, out string[] arguments)
+ {
+ string baseKey = PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName
+ + PlatformConfigurationConstants.KeyDelimiter
+ + optionName.Trim(CommandLineParseResult.OptionPrefix);
+
+ foreach (IConfigurationProvider provider in _configurationProviders)
+ {
+ List? collected = null;
+ int index = 0;
+ while (provider.TryGet(baseKey + PlatformConfigurationConstants.KeyDelimiter + index.ToString(CultureInfo.InvariantCulture), out string? indexed)
+ && indexed is not null)
+ {
+ collected ??= [];
+ collected.Add(indexed);
+ index++;
+ }
+
+ if (collected is { Count: > 0 })
+ {
+ arguments = [.. collected];
+ return true;
+ }
+
+ if (provider.TryGet(baseKey, out string? scalar) && scalar is not null)
+ {
+ arguments = [scalar];
+ return true;
+ }
+ }
+
+ arguments = [];
+ return false;
+ }
+
///
/// Returns the immediate (one-level) string entries declared under
/// in the loaded testconfig.json file. Returns an empty list if no JSON configuration source is
@@ -252,6 +290,18 @@ internal IReadOnlyList EnumerateJsonCommandLineOptio
return jsonProvider?.EnumerateCommandLineOptions() ?? [];
}
+ ///
+ /// Returns the typed passive command-line option defaults declared in the loaded testconfig.json file.
+ ///
+ internal IReadOnlyList EnumerateJsonCommandLineOptionDefaults()
+ {
+ JsonConfigurationSource.JsonConfigurationProvider? jsonProvider = _configurationProviders
+ .OfType()
+ .FirstOrDefault();
+
+ return jsonProvider?.EnumerateCommandLineOptionDefaults() ?? [];
+ }
+
///
/// Normalizes JSON-sourced scalar command-line option entries to the indexed shape for any
/// option in with >= 1,
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/ConfigurationExtensions.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/ConfigurationExtensions.cs
index 63cef1549a..d33ebadfd8 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/ConfigurationExtensions.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/ConfigurationExtensions.cs
@@ -178,11 +178,74 @@ internal static bool TryGetCommandLineOptionArguments(this IConfiguration config
return true;
}
+ ///
+ /// Returns the passive default argument list for an option without making that option active.
+ ///
+ internal static bool TryGetCommandLineOptionDefaultArguments(this IConfiguration configuration, string optionName, [NotNullWhen(true)] out string[]? arguments)
+ {
+ if (configuration is AggregatedConfiguration aggregated)
+ {
+ if (aggregated.TryGetCommandLineOptionDefaultFromProviders(optionName, out string[] args))
+ {
+ arguments = args;
+ return true;
+ }
+
+ arguments = null;
+ return false;
+ }
+
+ string baseKey = GetBaseKey(PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName, optionName);
+ List? collected = null;
+ int index = 0;
+ while (configuration[baseKey + PlatformConfigurationConstants.KeyDelimiter + index.ToString(CultureInfo.InvariantCulture)] is string indexed)
+ {
+ collected ??= [];
+ collected.Add(indexed);
+ index++;
+ }
+
+ if (collected is { Count: > 0 })
+ {
+ arguments = [.. collected];
+ return true;
+ }
+
+ if (configuration[baseKey] is string scalar)
+ {
+ arguments = [scalar];
+ return true;
+ }
+
+ arguments = null;
+ return false;
+ }
+
+ ///
+ /// Returns an explicit option argument list or its passive default while preserving an explicit
+ /// disable from a higher-priority provider.
+ ///
+ internal static bool TryGetCommandLineOptionArgumentsOrDefault(this IConfiguration configuration, string optionName, [NotNullWhen(true)] out string[]? arguments)
+ {
+ if (configuration is AggregatedConfiguration aggregated
+ && aggregated.TryGetCommandLineOptionFromProviders(optionName, out bool isSet, out string[] explicitArguments))
+ {
+ arguments = isSet ? explicitArguments : null;
+ return isSet;
+ }
+
+ return configuration.TryGetCommandLineOptionArguments(optionName, out arguments)
+ || configuration.TryGetCommandLineOptionDefaultArguments(optionName, out arguments);
+ }
+
private static string GetBaseKey(string optionName)
+ => GetBaseKey(PlatformConfigurationConstants.CommandLineOptionsSectionName, optionName);
+
+ private static string GetBaseKey(string sectionName, string optionName)
{
// Match CommandLineParseResult.IsOptionSet/TryGetOptionArgumentList: callers may pass
// either "--foo" or "foo", but storage is keyed by the bare name.
string trimmed = optionName.Trim(CommandLineParseResult.OptionPrefix);
- return PlatformConfigurationConstants.CommandLineOptionsSectionName + PlatformConfigurationConstants.KeyDelimiter + trimmed;
+ return sectionName + PlatformConfigurationConstants.KeyDelimiter + trimmed;
}
}
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonCommandLineOptionEntry.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonCommandLineOptionEntry.cs
index 8df78e3f77..79f0d4f08c 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonCommandLineOptionEntry.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonCommandLineOptionEntry.cs
@@ -4,8 +4,8 @@
namespace Microsoft.Testing.Platform.Configurations;
///
-/// A single command-line option entry materialized from the commandLineOptions section of a
-/// loaded testconfig.json file by .
+/// A single command-line option entry materialized from a command-line option section of a loaded
+/// testconfig.json file.
///
internal sealed class JsonCommandLineOptionEntry
{
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.cs
index f24d2fe900..bd0c7f8780 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.cs
@@ -61,7 +61,17 @@ private void VisitObjectElement(JsonElement element)
SetNullIfElementIsEmpty(isEmpty);
}
- private void SavePropertyToAllChildren(JsonProperty property)
+ private void SavePropertyToAllChildren(JsonProperty property) => SaveRawTextToAllChildren(property.Value.GetRawText());
+
+ // A JSON null array element flattens to a non-null placeholder string in _singleValueData (see
+ // VisitValue), so kvp.Value alone cannot tell a real empty-string scalar apart from a null element.
+ // Array elements are not JsonProperty instances (they have no name) like object properties are, so
+ // record the null element's raw JSON text ("null") separately, mirroring SavePropertyToAllChildren, to
+ // give callers the token-kind information needed to reject it. Non-null elements need no such marker:
+ // their flattened string already unambiguously distinguishes them from a null placeholder.
+ private void SaveNullArrayElementToAllChildren(JsonElement element) => SaveRawTextToAllChildren(element.GetRawText());
+
+ private void SaveRawTextToAllChildren(string rawText)
{
string key = _paths.Peek();
if (_propertyToAllChildren.ContainsKey(key))
@@ -69,7 +79,7 @@ private void SavePropertyToAllChildren(JsonProperty property)
throw new FormatException(string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonConfigurationFileParserDuplicateKeyErrorMessage, key));
}
- _propertyToAllChildren[key] = property.Value.GetRawText();
+ _propertyToAllChildren[key] = rawText;
}
private void VisitArrayElement(JsonElement element)
@@ -79,6 +89,11 @@ private void VisitArrayElement(JsonElement element)
foreach (JsonElement arrayElement in element.EnumerateArray())
{
EnterContext(index.ToString(CultureInfo.InvariantCulture));
+ if (arrayElement.ValueKind == JsonValueKind.Null)
+ {
+ SaveNullArrayElementToAllChildren(arrayElement);
+ }
+
VisitValue(arrayElement);
ExitContext();
index++;
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.netstandard.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.netstandard.cs
index 33248842f4..99debf298a 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.netstandard.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationFileParser.netstandard.cs
@@ -75,6 +75,19 @@ private void VisitArrayElement(JsonArray array)
foreach (object arrayElement in array)
{
EnterContext(index.ToString(CultureInfo.InvariantCulture));
+
+ // A JSON null array element flattens to the literal "null" placeholder string in
+ // _singleValueData (see VisitValue), so the flattened value alone cannot tell a real "null"
+ // string scalar apart from an actual null element. Array elements have no name (unlike object
+ // properties), so record the null element's raw JSON text separately, mirroring
+ // SavePropertyToAllChildren, to give callers the token-kind information needed to reject it.
+ // Non-null elements need no such marker: their flattened string already unambiguously
+ // distinguishes them from a null placeholder.
+ if (arrayElement is null)
+ {
+ SavePropertyToAllChildren(arrayElement);
+ }
+
VisitValue(arrayElement);
ExitContext();
index++;
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs
index 814ed7367f..016cce08b7 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs
@@ -33,14 +33,33 @@ internal sealed partial class JsonConfigurationProvider
///
///
///
- /// Empty containers ("foo": []) are skipped (treated as absent). Empty objects ("foo": {})
- /// are rejected because that shape is otherwise indistinguishable from a malformed nested entry.
+ /// Empty arrays under commandLineOptions are skipped (treated as absent). Passive defaults
+ /// reject empty arrays and null values because they must provide at least one argument. This
+ /// includes a JSON null array element (e.g. "foo": ["a", null]), even inside a mixed
+ /// array of otherwise-valid scalars. Empty objects are rejected for both sections.
///
///
internal IReadOnlyList EnumerateCommandLineOptions()
- {
- const string sectionName = PlatformConfigurationConstants.CommandLineOptionsSectionName;
+ => EnumerateCommandLineOptionEntries(
+ PlatformConfigurationConstants.CommandLineOptionsSectionName,
+ allowBooleanMarkers: true,
+ requireValue: false);
+ ///
+ /// Enumerates passive option argument defaults from the commandLineOptionDefaults section.
+ /// Scalar booleans are treated as argument values because defaults cannot represent option presence.
+ ///
+ internal IReadOnlyList EnumerateCommandLineOptionDefaults()
+ => EnumerateCommandLineOptionEntries(
+ PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName,
+ allowBooleanMarkers: false,
+ requireValue: true);
+
+ private IReadOnlyList EnumerateCommandLineOptionEntries(
+ string sectionName,
+ bool allowBooleanMarkers,
+ bool requireValue)
+ {
Dictionary singleValueData = _singleValueData ?? [];
Dictionary propertyToAllChildren = _propertyToAllChildren ?? [];
@@ -110,20 +129,28 @@ internal IReadOnlyList EnumerateCommandLineOptions()
if (subKey is null)
{
+ _ = propertyToAllChildren.TryGetValue(kvp.Key, out string? rawEntry);
+ if (requireValue && string.Equals(rawEntry?.Trim(), "null", StringComparison.OrdinalIgnoreCase))
+ {
+ ThrowDefaultEntryMustHaveValue(kvp.Key, sectionName);
+ }
+
if (kvp.Value is null)
{
- // Empty container at the option key. The parser cannot tell the difference between
- // {} and [] at write time, so we disambiguate via the raw text recorded for object
- // values. Empty arrays are silently dropped (matches runtime behavior — no entries
- // means "absent"); empty objects are rejected as the user almost certainly meant a
- // typo and the entire commandLineOptions section is supposed to hold leaf values.
- if (propertyToAllChildren.TryGetValue(kvp.Key, out string? rawEntry)
- && rawEntry is not null
+ // Empty containers share a null flattened value, so use the retained raw JSON to
+ // distinguish objects from arrays. Objects are always invalid; empty arrays remain
+ // absent only for the established commandLineOptions behavior.
+ if (rawEntry is not null
&& StartsWithChar(rawEntry, '{'))
{
ThrowEntryMustBeScalarOrArray(kvp.Key, sectionName);
}
+ if (requireValue)
+ {
+ ThrowDefaultEntryMustHaveValue(kvp.Key, sectionName);
+ }
+
// Empty array — treat as absent.
continue;
}
@@ -160,6 +187,23 @@ internal IReadOnlyList EnumerateCommandLineOptions()
ThrowEntryMustBeScalarOrArray(kvp.Key, sectionName);
}
+ if (requireValue)
+ {
+ // A JSON null array element (e.g. "foo": ["a", null]) flattens to a non-null
+ // placeholder in kvp.Value ("" for the System.Text.Json parser, the literal "null"
+ // string for the Jsonite parser), so kvp.Value alone cannot tell a real empty-string
+ // scalar apart from a null element. The parser separately preserves the element's raw
+ // JSON text in _propertyToAllChildren (see JsonConfigurationFileParser.VisitArrayElement),
+ // which is "null" for both parsers regardless of how they flatten it — use that to
+ // reject null elements in defaults arrays (including mixed arrays), without touching
+ // the established commandLineOptions behavior for allowBooleanMarkers callers.
+ _ = propertyToAllChildren.TryGetValue(kvp.Key, out string? indexedRawEntry);
+ if (string.Equals(indexedRawEntry?.Trim(), "null", StringComparison.OrdinalIgnoreCase))
+ {
+ ThrowDefaultEntryMustHaveValue(kvp.Key, sectionName);
+ }
+ }
+
builder.Indexed ??= [];
builder.Indexed[idx] = kvp.Value!;
}
@@ -181,7 +225,7 @@ internal IReadOnlyList EnumerateCommandLineOptions()
if (builder.Scalar is not null)
{
- if (bool.TryParse(builder.Scalar, out bool boolValue))
+ if (allowBooleanMarkers && bool.TryParse(builder.Scalar, out bool boolValue))
{
result.Add(new JsonCommandLineOptionEntry(optionName, [], isDisabled: !boolValue));
}
@@ -235,5 +279,21 @@ private void ThrowEntryMustBeScalarOrArray(string fullKey, string sectionName)
sectionName,
ConfigurationFile ?? ""));
}
+
+ [DoesNotReturn]
+ private void ThrowDefaultEntryMustHaveValue(string fullKey, string sectionName)
+ {
+ string prefix = sectionName + PlatformConfigurationConstants.KeyDelimiter;
+ string entryName = fullKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ ? fullKey.Substring(prefix.Length)
+ : fullKey;
+
+ throw new FormatException(string.Format(
+ CultureInfo.InvariantCulture,
+ PlatformResources.JsonCommandLineOptionDefaultMustHaveValueErrorMessage,
+ entryName,
+ sectionName,
+ ConfigurationFile ?? ""));
+ }
}
}
diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/PlatformConfigurationConstants.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/PlatformConfigurationConstants.cs
index 2d2224f243..ddf7b8903c 100644
--- a/src/Platform/Microsoft.Testing.Platform/Configurations/PlatformConfigurationConstants.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Configurations/PlatformConfigurationConstants.cs
@@ -1,6 +1,8 @@
// 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.CommandLine;
+
namespace Microsoft.Testing.Platform.Configurations;
internal static class PlatformConfigurationConstants
@@ -24,4 +26,11 @@ internal static class PlatformConfigurationConstants
/// argument for multi-value options).
///
public const string CommandLineOptionsSectionName = "commandLineOptions";
+
+ ///
+ /// Root section name for passive command-line option argument defaults. Values in this section
+ /// are consulted only by
+ /// and never make an option active.
+ ///
+ public const string CommandLineOptionDefaultsSectionName = "commandLineOptionDefaults";
}
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs
index 263bf5cfb5..8140740c56 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs
@@ -229,6 +229,7 @@ private async Task SetupCommonServicesAsync(
serviceProvider.AddService(context.TestFrameworkCapabilities);
IReadOnlyList jsonCommandLineOptions;
+ IReadOnlyList jsonCommandLineOptionDefaults;
try
{
// Normalize JSON-sourced scalar option entries to the indexed shape for arg-bearing
@@ -262,6 +263,7 @@ loggingState.CommandLineParseResult.ToolName is string toolName
context.Configuration.NormalizeJsonCommandLineOptionScalars(optionByName);
jsonCommandLineOptions = context.Configuration.EnumerateJsonCommandLineOptions();
+ jsonCommandLineOptionDefaults = context.Configuration.EnumerateJsonCommandLineOptionDefaults();
}
catch (FormatException ex) when (!loggingState.CommandLineParseResult.HasTool)
{
@@ -281,6 +283,7 @@ loggingState.CommandLineParseResult.ToolName is string toolName
// A tool such as --info or --version is being invoked. Degrade gracefully by treating
// the malformed testconfig.json as empty so the tool can still complete its job.
jsonCommandLineOptions = [];
+ jsonCommandLineOptionDefaults = [];
}
ValidationResult commandLineValidationResult = await CommandLineOptionsValidator.ValidateAsync(
@@ -288,7 +291,8 @@ loggingState.CommandLineParseResult.ToolName is string toolName
context.CommandLineHandler.SystemCommandLineOptionsProviders,
context.CommandLineHandler.ExtensionsCommandLineOptionsProviders,
context.CommandLineHandler,
- jsonCommandLineOptions).ConfigureAwait(false);
+ jsonCommandLineOptions,
+ jsonCommandLineOptionDefaults).ConfigureAwait(false);
if (!commandLineValidationResult.IsValid)
{
diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt
index dde4533be8..2a1cb47c67 100644
--- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,14 @@
#nullable enable
+const Microsoft.Testing.Platform.Configurations.PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName = "commandLineOptionDefaults" -> string!
+Microsoft.Testing.Platform.CommandLine.ICommandLineOptionsWithDefaults
+Microsoft.Testing.Platform.CommandLine.ICommandLineOptionsWithDefaults.TryGetOptionArgumentListOrDefault(string! optionName, out string![]? arguments) -> bool
+Microsoft.Testing.Platform.Configurations.AggregatedConfiguration.EnumerateJsonCommandLineOptionDefaults() -> System.Collections.Generic.IReadOnlyList!
+Microsoft.Testing.Platform.Configurations.AggregatedConfiguration.TryGetCommandLineOptionDefaultFromProviders(string! optionName, out string![]! arguments) -> bool
+Microsoft.Testing.Platform.Configurations.JsonConfigurationSource.JsonConfigurationProvider.EnumerateCommandLineOptionDefaults() -> System.Collections.Generic.IReadOnlyList!
+static Microsoft.Testing.Platform.CommandLine.CommandLineOptionsValidator.ValidateAsync(Microsoft.Testing.Platform.CommandLine.CommandLineParseResult! commandLineParseResult, System.Collections.Generic.IEnumerable! systemCommandLineOptionsProviders, System.Collections.Generic.IEnumerable! extensionCommandLineOptionsProviders, Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, System.Collections.Generic.IReadOnlyList? jsonCommandLineOptions = null, System.Collections.Generic.IReadOnlyList? jsonCommandLineOptionDefaults = null) -> System.Threading.Tasks.Task!
+static Microsoft.Testing.Platform.Configurations.ConfigurationExtensions.TryGetCommandLineOptionDefaultArguments(this Microsoft.Testing.Platform.Configurations.IConfiguration! configuration, string! optionName, out string![]? arguments) -> bool
+static Microsoft.Testing.Platform.Configurations.ConfigurationExtensions.TryGetCommandLineOptionArgumentsOrDefault(this Microsoft.Testing.Platform.Configurations.IConfiguration! configuration, string! optionName, out string![]? arguments) -> bool
+*REMOVED*static Microsoft.Testing.Platform.CommandLine.CommandLineOptionsValidator.ValidateAsync(Microsoft.Testing.Platform.CommandLine.CommandLineParseResult! commandLineParseResult, System.Collections.Generic.IEnumerable! systemCommandLineOptionsProviders, System.Collections.Generic.IEnumerable! extensionCommandLineOptionsProviders, Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, System.Collections.Generic.IReadOnlyList? jsonCommandLineOptions = null) -> System.Threading.Tasks.Task!
abstract Microsoft.Testing.Platform.Hosts.CommonHost.InternalRunAsync(System.Threading.CancellationToken cancellationToken, System.Collections.Generic.List