From ed78fe39b17443860b076709041fd58076c89c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 31 Aug 2026 20:18:38 +0200 Subject: [PATCH 1/5] Add passive command-line option defaults Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f86614b5-b40f-415d-8f94-a3290fbe7154 --- docs/testconfig.schema.json | 18 + docs/testconfig.schema.md | 3 +- .../TrxReportEngine.cs | 4 +- .../FileSystem.cs | 2 + .../IFileSystem.cs | 2 + .../Microsoft.Testing.Platform.MSBuild.csproj | 2 + .../PACKAGE.md | 22 +- .../Tasks/ConfigurationFileTask.cs | 344 +++++++++++++++++- ...Microsoft.Testing.Platform.MSBuild.targets | 21 +- .../CommandLine/CommandLineHandler.cs | 7 +- .../CommandLine/CommandLineOptionsProxy.cs | 7 +- ...ator.ArgumentAndConfigurationValidation.cs | 27 +- ...andLineOptionsValidator.ArityValidation.cs | 34 +- ...Validator.UnknownAndBootstrapValidation.cs | 78 ++-- .../CommandLineOptionsValidator.cs | 16 +- .../CommandLine/ICommandLineOptions.cs | 36 ++ .../Configurations/AggregatedConfiguration.cs | 50 +++ .../Configurations/ConfigurationExtensions.cs | 65 +++- .../JsonCommandLineOptionEntry.cs | 4 +- ...onfigurationProvider.CommandLineOptions.cs | 14 +- .../PlatformConfigurationConstants.cs | 9 + .../Hosts/TestHostBuilder.CommonServices.cs | 6 +- .../InternalAPI/InternalAPI.Unshipped.txt | 10 + .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../Resources/PlatformResources.resx | 8 +- .../Resources/xlf/PlatformResources.cs.xlf | 11 +- .../Resources/xlf/PlatformResources.de.xlf | 11 +- .../Resources/xlf/PlatformResources.es.xlf | 11 +- .../Resources/xlf/PlatformResources.fr.xlf | 11 +- .../Resources/xlf/PlatformResources.it.xlf | 11 +- .../Resources/xlf/PlatformResources.ja.xlf | 11 +- .../Resources/xlf/PlatformResources.ko.xlf | 11 +- .../Resources/xlf/PlatformResources.pl.xlf | 11 +- .../Resources/xlf/PlatformResources.pt-BR.xlf | 11 +- .../Resources/xlf/PlatformResources.ru.xlf | 11 +- .../Resources/xlf/PlatformResources.tr.xlf | 11 +- .../xlf/PlatformResources.zh-Hans.xlf | 11 +- .../xlf/PlatformResources.zh-Hant.xlf | 11 +- .../ReportEngineBase.cs | 4 +- .../MSBuildTests.ConfigurationFile.cs | 58 +++ .../TrxTests.cs | 42 +++ .../InvokeTestingPlatformTaskTests.cs | 2 + .../MSBuildTests.cs | 123 ++++++- .../CommandLine/CommandLineHandlerTests.cs | 44 +++ .../JsonCommandLineOptionsTests.cs | 125 ++++++- 45 files changed, 1202 insertions(+), 130 deletions(-) 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..9389fce119 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)') " /> + @@ -42,14 +48,15 @@ DependsOnTargets="_CalculateGenerateTestingPlatformConfigurationFile;_GenerateTestingPlatformConfigurationFileCore" /> + Condition=" '$(GenerateTestingPlatformConfigurationFile)' == 'true' And (Exists('$(_TestingPlatformConfigurationFileSourcePath)') Or '@(TestingPlatformCommandLineOptionDefault)' != '') " > + TestingPlatformConfigurationFileSource="$(_TestingPlatformConfigurationFileSourcePath)" + TestingPlatformCommandLineOptionDefault="@(TestingPlatformCommandLineOptionDefault)" > @@ -91,14 +98,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..04ade25aad 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,11 @@ 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.TryGetOptionArgumentListOrDefault(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..8fb47b7ec0 100644 --- a/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs @@ -23,3 +23,39 @@ 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)); + + 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/JsonConfigurationProvider.CommandLineOptions.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs index 814ed7367f..afe337c7c8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs @@ -38,9 +38,17 @@ internal sealed partial class JsonConfigurationProvider /// /// internal IReadOnlyList EnumerateCommandLineOptions() - { - const string sectionName = PlatformConfigurationConstants.CommandLineOptionsSectionName; + => EnumerateCommandLineOptionEntries(PlatformConfigurationConstants.CommandLineOptionsSectionName, allowBooleanMarkers: true); + /// + /// 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); + + private IReadOnlyList EnumerateCommandLineOptionEntries(string sectionName, bool allowBooleanMarkers) + { Dictionary singleValueData = _singleValueData ?? []; Dictionary propertyToAllChildren = _propertyToAllChildren ?? []; @@ -181,7 +189,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)); } 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! alreadyDisposed) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.Configurations.AggregatedConfiguration.GetChildren() -> System.Collections.Generic.IEnumerable! Microsoft.Testing.Platform.Configurations.AggregatedConfiguration.GetChildren(string? parentPath) -> System.Collections.Generic.IEnumerable! diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 656397a1b2..8b96b02ab2 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Microsoft.Testing.Platform.CommandLine.CommandLineOptionsExtensions +static Microsoft.Testing.Platform.CommandLine.CommandLineOptionsExtensions.TryGetOptionArgumentListOrDefault(this Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions, string! optionName, out string![]? arguments) -> bool [TPEXP]Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionResultCapability [TPEXP]Microsoft.Testing.Platform.Capabilities.TestFramework.IGracefulStopTestExecutionResultCapability.TryStopTestExecutionAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.Configurations.IConfigurationRoot diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx index 8fd5f1726b..d26d5eefc3 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -266,9 +266,13 @@ In testconfig.json under 'commandLineOptions': {0} {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptions"} + + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} testconfig.json environment variables provider diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index 747bc193db..7529314f34 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -644,10 +644,15 @@ Neplatné argumenty příkazového řádku: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - Možnost --{0} se načítá během spouštění platformy (před načtením souboru testconfig.json), a proto musí být předána v příkazovém řádku, nikoli deklarována v části commandLineOptions souboru testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index 8faaad74cd..2aa17224e8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -644,10 +644,15 @@ Ungültige Befehlszeilenargumente: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - Die Option „--{0}“ wird während des Plattform-Bootstraps gelesen (bevor die Datei testconfig.json geladen wird) und muss daher in der Befehlszeile übergeben werden, statt unter „commandLineOptions“ in testconfig.json angegeben zu werden. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index 5e4090d579..10dc574286 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -644,10 +644,15 @@ Argumentos de línea de comandos no válidos: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - La opción "--{0}" se lee durante el arranque de la plataforma (antes de cargar el archivo testconfig.json) y, por tanto, debe pasarse en la línea de comandos en lugar de declararse en "commandLineOptions" en testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index 00976bb203..50a13085f5 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -644,10 +644,15 @@ Arguments de ligne de commande non valides : + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - L’option « --{0} » est lue lors de l’amorçage de la plateforme (avant le chargement du fichier testconfig.json) et doit donc être passée sur la ligne de commande plutôt que déclarée sous « commandLineOptions » dans testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index 72b082cbbc..fdc6387b9f 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -644,10 +644,15 @@ Argomenti della riga di comando non validi: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - L'opzione '--{0}' viene letta durante il bootstrap della piattaforma (prima del caricamento del file testconfig.json) e deve quindi essere passata sulla riga di comando anziché dichiarata in 'commandLineOptions' in testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 1528111de8..9a82bbf425 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -644,10 +644,15 @@ コマンド ラインの引数が無効です: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - オプション '--{0}' はプラットフォームのブートストラップ中 (testconfig.json ファイルの読み込み前) に読み取られるため、testconfig.json の 'commandLineOptions' ではなく、コマンド ラインで指定する必要があります。 - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 0d8112de6b..7c298e024d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -644,10 +644,15 @@ 잘못된 명령줄 인수: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - '--{0}' 옵션은 플랫폼 부트스트랩 중에 읽히며(testconfig.json 파일이 로드되기 전), 따라서 testconfig.json의 'commandLineOptions' 아래에 선언하는 대신 명령줄에서 전달해야 합니다. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index 56ccd9143d..17d2fa7d45 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -644,10 +644,15 @@ Nieprawidłowe argumenty wiersza polecenia: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - Opcja „--{0}” jest odczytywana podczas ładowania początkowego platformy (przed załadowaniem pliku testconfig.json) i dlatego musi być przekazywana w wierszu polecenia, a nie zadeklarowana w obszarze „commandLineOptions” w pliku testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index cff9002756..a2533e1f02 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -644,10 +644,15 @@ Argumentos de linha de comando inválidos: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - A opção '--{0}' é lida durante a inicialização da plataforma (antes que o arquivo testconfig.json seja carregado) e, portanto, deve ser passada na linha de comando em vez de declarada em 'commandLineOptions' no testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index 67a6f8dbbe..fb22aca7d1 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -644,10 +644,15 @@ Недопустимые аргументы командной строки: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - Параметр "--{0}" считывается во время начальной загрузки платформы (до загрузки файла testconfig.json) и поэтому должен передаваться в командной строке, а не указываться в "commandLineOptions" в testconfig.json. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index 63d9419b3a..c54137dbcb 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -644,10 +644,15 @@ Geçersiz komut satırı bağımsız değişkenleri: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - '--{0}' seçeneği, platformda önyükleme sırasında (testconfig.json dosyası yüklenmeden önce) okunur. Bu nedenle, testconfig.json içinde 'commandLineOptions' altında değil komut satırında bildirilmelidir. - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index e50782305e..deb9a9bded 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -644,10 +644,15 @@ 无效的命令行参数: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - 系统会在平台启动时(在加载 testconfig.json 文件前)读取选项“--{0}”,因此必须通过命令行传递,而不能在 testconfig.json 的 "commandLineOptions" 下声明。 - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index 897bca0219..9a0f41a467 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -644,10 +644,15 @@ 命令列引數無效: + + In testconfig.json under 'commandLineOptionDefaults': {0} + In testconfig.json under 'commandLineOptionDefaults': {0} + {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + - The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under 'commandLineOptions' in testconfig.json. - 選項 '--{0}' 在平台啟動程序期間讀取 (載入 testconfig.json 檔案之前),因此必須在命令列上傳遞,而不是在 testconfig.json 中的 'commandLineOptions' 之下宣告。 - {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {Locked="--"}{Locked="commandLineOptions"}{Locked="testconfig.json"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. + {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must be either a scalar value (string, number, boolean) or an array of scalar values. Nested objects are not supported. diff --git a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs b/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs index 6f6f39fb5d..a90205cc1a 100644 --- a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs +++ b/src/Platform/SharedExtensionHelpers/ReportEngineBase.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.Platform; @@ -94,7 +94,7 @@ internal static string BuildDefaultFileName(string testApplicationModule, string { _cancellationToken.ThrowIfCancellationRequested(); - bool wasExplicit = _commandLineOptions.TryGetOptionArgumentList(fileNameOptionName, out string[]? providedFileName); + bool wasExplicit = _commandLineOptions.TryGetOptionArgumentListOrDefault(fileNameOptionName, out string[]? providedFileName); string fileName = wasExplicit ? ResolveProvidedFileName(GetProvidedFileName(providedFileName)) : defaultFileNameFactory(); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs index 5cc3bf9fcb..edb02e2ca1 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.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 System.Text.Json; + using Combinatorial.MSTest; namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; @@ -92,6 +94,36 @@ public async Task ConfigFileGeneration_NoConfigurationFile_TaskWontRun([AllTarge Assert.IsFalse(File.Exists(generatedConfigurationFile)); } + [TestMethod] + public async Task ConfigFileGeneration_OptionDefaultsGenerateConfigurationWithoutSourceFile() + { + using TestAsset testAsset = await TestAsset.GenerateAssetAsync( + nameof(ConfigFileGeneration_OptionDefaultsGenerateConfigurationWithoutSourceFile), + OptionDefaultsSourceCode + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + + await DotnetCli.RunAsync($"build -v:normal {testAsset.TargetAssetPath} -c Release", cancellationToken: TestContext.CancellationToken); + + var testHost = TestInfrastructure.TestHost.LocateFrom(testAsset.TargetAssetPath, "MSBuildOptionDefaults", "net8.0", buildConfiguration: BuildConfiguration.Release); + string generatedConfigurationFile = Path.Combine(testHost.DirectoryName, "MSBuildOptionDefaults.testconfig.json"); + Assert.IsTrue(File.Exists(generatedConfigurationFile)); + using var document = JsonDocument.Parse(File.ReadAllText(generatedConfigurationFile)); + JsonElement defaults = document.RootElement.GetProperty("commandLineOptionDefaults"); + Assert.AreEqual("{asm}.trx", defaults.GetProperty("report-trx-filename").GetString()); + Assert.AreSequenceEqual( + ["first", "second"], + defaults.GetProperty("filter-uid").EnumerateArray().Select(x => x.GetString()).ToArray()); + + await DotnetCli.RunAsync( + $"build -v:normal {testAsset.TargetAssetPath} -c Release /p:TrxFileName=changed.trx", + cancellationToken: TestContext.CancellationToken); + + using var updatedDocument = JsonDocument.Parse(File.ReadAllText(generatedConfigurationFile)); + Assert.AreEqual( + "changed.trx", + updatedDocument.RootElement.GetProperty("commandLineOptionDefaults").GetProperty("report-trx-filename").GetString()); + } + private const string ConfigurationContent = """ { "platformOptions": { @@ -163,5 +195,31 @@ public Task ExecuteRequestAsync(ExecuteRequestContext context) } """; + private const string OptionDefaultsSourceCode = """ + #file MSBuildOptionDefaults.csproj + + + net8.0 + Exe + false + {asm}.trx + + + + + + + + + + #file Program.cs + public static class Program + { + public static void Main() + { + } + } + """; + public TestContext TestContext { get; set; } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs index 2ccd6a1e08..5a9437df72 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs @@ -38,6 +38,48 @@ public async Task Trx_WhenReportTrxIsSpecified_TrxReportIsGeneratedInDefaultLoca await AssertTrxReportWasGeneratedAsync(testHostResult, trxPathPattern, 1); } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task Trx_CommandLineOptionDefault_IsPassiveAndExplicitValueWins(string tfm) + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); + using TempDirectory clone = new(); + testHost = await CloneTestHostAsync(testHost, clone, TestAssetFixture.AssetName); + string configFile = Path.Combine(testHost.DirectoryName, $"{TestAssetFixture.AssetName}.testconfig.json"); + await File.WriteAllTextAsync( + configFile, + """ + { + "commandLineOptionDefaults": { + "report-trx-filename": "configured-{asm}.trx" + } + } + """, + TestContext.CancellationToken); + + TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + Assert.IsEmpty(Directory.GetFiles(testHost.DirectoryName, "configured-*.trx", SearchOption.AllDirectories)); + + string defaultResultsPath = Path.Combine(testHost.DirectoryName, "default-results"); + testHostResult = await testHost.ExecuteAsync( + $"--report-trx --results-directory \"{defaultResultsPath}\"", + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + Assert.IsTrue(File.Exists(Path.Combine(defaultResultsPath, $"configured-{TestAssetFixture.AssetName}.trx"))); + + string explicitResultsPath = Path.Combine(testHost.DirectoryName, "explicit-results"); + testHostResult = await testHost.ExecuteAsync( + $"--report-trx --report-trx-filename explicit.trx --results-directory \"{explicitResultsPath}\"", + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + Assert.IsTrue(File.Exists(Path.Combine(explicitResultsPath, "explicit.trx"))); + Assert.IsFalse(File.Exists(Path.Combine(explicitResultsPath, $"configured-{TestAssetFixture.AssetName}.trx"))); + } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] [TestMethod] public async Task Trx_WhenOnlyReportTrxIsSpecified_UsesControllerBackedRecoveryByDefault(string tfm) diff --git a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/InvokeTestingPlatformTaskTests.cs b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/InvokeTestingPlatformTaskTests.cs index 8140c4e0f2..201fdbe0d2 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/InvokeTestingPlatformTaskTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/InvokeTestingPlatformTaskTests.cs @@ -272,6 +272,8 @@ private sealed class StubFileSystem : IFileSystem public void CopyFile(string source, string destination) => throw new NotSupportedException(); + public string ReadAllText(string path) => throw new NotSupportedException(); + public void WriteAllText(string path, string? contents) => throw new NotSupportedException(); } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs index 824e4e13a7..735b758e73 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.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 System.Text.Json; + using Microsoft.Build.Framework; using Moq; @@ -158,19 +160,134 @@ public void SelfRegisteredExtensions_Fails_For_Duplicate_BuilderHook_Ids_With_Di Assert.Contains("Duplicate 'TestingPlatformBuilderHook' item with Include 'hook' has conflicting metadata.", _errors[0].Message ?? string.Empty); } + [TestMethod] + public void ConfigurationFileTask_MergesOptionDefaultsAndPreservesJsonOverrides() + { + string projectDirectory = Path.Combine("root", "project"); + string sourcePath = Path.Combine(projectDirectory, "testconfig.json"); + string outputPath = Path.Combine(projectDirectory, "bin", "Tests.testconfig.json"); + InMemoryFileSystem fileSystem = new(); + fileSystem.Files[sourcePath] = + """ + { + "platformOptions": { + "exitProcessOnUnhandledException": true + }, + "commandLineOptionDefaults": { + "report-trx-filename": "from-json.trx" + } + } + """; + ConfigurationFileTask task = CreateConfigurationFileTask(fileSystem, projectDirectory); + task.TestingPlatformCommandLineOptionDefault = + [ + new CustomTaskItem("report-trx-filename").Add("Value", "from-msbuild.trx"), + new CustomTaskItem("filter-uid").Add("Value", "first"), + new CustomTaskItem("filter-uid").Add("Value", "second"), + ]; + + Assert.IsTrue(task.Execute()); + + string? output = fileSystem.Files[outputPath]; + Assert.IsNotNull(output); + using var document = JsonDocument.Parse(output); + JsonElement root = document.RootElement; + Assert.IsTrue(root.GetProperty("platformOptions").GetProperty("exitProcessOnUnhandledException").GetBoolean()); + JsonElement defaults = root.GetProperty("commandLineOptionDefaults"); + Assert.AreEqual("from-json.trx", defaults.GetProperty("report-trx-filename").GetString()); + Assert.AreSequenceEqual( + ["first", "second"], + defaults.GetProperty("filter-uid").EnumerateArray().Select(x => x.GetString()).ToArray()); + Assert.IsEmpty(_errors); + } + + [TestMethod] + public void ConfigurationFileTask_GeneratesConfigurationFromOptionDefaults() + { + string projectDirectory = Path.Combine("root", "project"); + string outputPath = Path.Combine(projectDirectory, "bin", "Tests.testconfig.json"); + InMemoryFileSystem fileSystem = new(); + ConfigurationFileTask task = CreateConfigurationFileTask(fileSystem, projectDirectory); + task.TestingPlatformCommandLineOptionDefault = + [ + new CustomTaskItem("report-trx-filename").Add("Value", "{asm}.trx"), + ]; + + Assert.IsTrue(task.Execute()); + + string? output = fileSystem.Files[outputPath]; + Assert.IsNotNull(output); + using var document = JsonDocument.Parse(output); + Assert.AreEqual( + "{asm}.trx", + document.RootElement.GetProperty("commandLineOptionDefaults").GetProperty("report-trx-filename").GetString()); + Assert.IsEmpty(_errors); + } + + [TestMethod] + public void ConfigurationFileTask_RejectsOptionNameWithLeadingHyphens() + { + InMemoryFileSystem fileSystem = new(); + ConfigurationFileTask task = CreateConfigurationFileTask(fileSystem, Path.Combine("root", "project")); + task.TestingPlatformCommandLineOptionDefault = + [ + new CustomTaskItem("--report-trx-filename").Add("Value", "{asm}.trx"), + ]; + + Assert.IsFalse(task.Execute()); + Assert.Contains("without leading hyphens", Assert.ContainsSingle(_errors).Message ?? string.Empty); + } + + [TestMethod] + public void ConfigurationFileTask_ReportsDuplicateJsonKeys() + { + string projectDirectory = Path.Combine("root", "project"); + string sourcePath = Path.Combine(projectDirectory, "testconfig.json"); + InMemoryFileSystem fileSystem = new(); + fileSystem.Files[sourcePath] = + """ + { + "commandLineOptionDefaults": {}, + "commandLineOptionDefaults": {} + } + """; + ConfigurationFileTask task = CreateConfigurationFileTask(fileSystem, projectDirectory); + task.TestingPlatformCommandLineOptionDefault = + [ + new CustomTaskItem("report-trx-filename").Add("Value", "{asm}.trx"), + ]; + + Assert.IsFalse(task.Execute()); + Assert.Contains("duplicate keys", Assert.ContainsSingle(_errors).Message ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + private ConfigurationFileTask CreateConfigurationFileTask(InMemoryFileSystem fileSystem, string projectDirectory) + => new(fileSystem) + { + BuildEngine = _buildEngine.Object, + TestingPlatformConfigurationFileSource = new CustomTaskItem(Path.Combine(projectDirectory, "testconfig.json")), + MSBuildProjectDirectory = new CustomTaskItem(projectDirectory), + AssemblyName = new CustomTaskItem("Tests"), + OutputPath = new CustomTaskItem("bin"), + }; + private sealed class InMemoryFileSystem : IFileSystem { public Dictionary Files { get; } = []; - public void CopyFile(string source, string destination) => throw new NotImplementedException(); + public void CopyFile(string source, string destination) => Files[destination] = Files[source]; - public void CreateDirectory(string directory) => throw new NotImplementedException(); + public void CreateDirectory(string directory) + { + } public Stream CreateNew(string path) => throw new NotImplementedException(); public bool Exist(string path) => Files.ContainsKey(path); - public void WriteAllText(string path, string? contents) => Files.Add(path, contents); + public string ReadAllText(string path) => Files[path]!; + + public void WriteAllText(string path, string? contents) => Files[path] = contents; } private sealed class CustomTaskItem : ITaskItem diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs index ef8ca8690b..2bb4292bfa 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs @@ -1138,6 +1138,50 @@ public void GetOptionValue_OptionDoesNotExist_ReturnsNull() Assert.IsNull(optionValue); } + [TestMethod] + public void GetOptionValueOrDefault_DefaultDoesNotActivateOption() + { + Mock configuration = new(); + configuration + .Setup(x => x["commandLineOptionDefaults:name:0"]) + .Returns("default-value"); + CommandLineHandler commandLineHandler = new( + CommandLineParseResult.Empty, + _extensionCommandLineOptionsProviders, + _systemCommandLineOptionsProviders, + _testApplicationModuleInfoMock.Object, + _runtimeFeatureMock.Object, + configuration.Object); + + Assert.IsFalse(commandLineHandler.IsOptionSet("name")); + Assert.IsFalse(commandLineHandler.TryGetOptionArgumentList("name", out _)); + Assert.IsTrue(commandLineHandler.TryGetOptionArgumentListOrDefault("name", out string[]? arguments)); + Assert.AreSequenceEqual(["default-value"], arguments); + } + + [TestMethod] + public void GetOptionValueOrDefault_ExplicitValueWins() + { + Mock configuration = new(); + configuration + .Setup(x => x["commandLineOptions:name:0"]) + .Returns("explicit-value"); + configuration + .Setup(x => x["commandLineOptionDefaults:name:0"]) + .Returns("default-value"); + CommandLineHandler commandLineHandler = new( + CommandLineParseResult.Empty, + _extensionCommandLineOptionsProviders, + _systemCommandLineOptionsProviders, + _testApplicationModuleInfoMock.Object, + _runtimeFeatureMock.Object, + configuration.Object); + + Assert.IsTrue(commandLineHandler.IsOptionSet("name")); + Assert.IsTrue(commandLineHandler.TryGetOptionArgumentListOrDefault("name", out string[]? arguments)); + Assert.AreSequenceEqual(["explicit-value"], arguments); + } + private sealed class ExtensionCommandLineProviderMockReservedOptions : ICommandLineOptionsProvider { public const string HelpOption = "help"; diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs index e2d622557c..47f558c503 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.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.Platform.CommandLine; @@ -177,6 +177,51 @@ public async Task EnumerateCommandLineOptions_MixedEntries_AllReturned() Assert.AreSequenceEqual(["a", "b"], filterUid.Arguments.ToArray()); } + [TestMethod] + public async Task EnumerateCommandLineOptionDefaults_ScalarsAndArrays_AreArguments() + { + AggregatedConfiguration configuration = await BuildAggregatedAsync( + """ + { + "commandLineOptionDefaults": { + "timeout": "30s", + "boolean-value": false, + "filter-uid": ["a", "b"] + } + } + """); + + IReadOnlyList entries = configuration.EnumerateJsonCommandLineOptionDefaults(); + + Assert.HasCount(3, entries); + Assert.AreSequenceEqual(["30s"], entries.Single(x => x.OptionName == "timeout").Arguments.ToArray()); + Assert.AreSequenceEqual(["False"], entries.Single(x => x.OptionName == "boolean-value").Arguments.ToArray()); + Assert.AreSequenceEqual(["a", "b"], entries.Single(x => x.OptionName == "filter-uid").Arguments.ToArray()); + Assert.IsTrue(configuration.TryGetCommandLineOptionDefaultFromProviders("boolean-value", out string[] booleanArguments)); + Assert.AreSequenceEqual(["False"], booleanArguments); + Assert.IsFalse(configuration.TryGetCommandLineOptionFromProviders("timeout", out bool isSet, out _)); + Assert.IsFalse(isSet); + } + + [TestMethod] + public async Task ExplicitJsonDisable_SuppressesConfiguredDefault() + { + AggregatedConfiguration configuration = await BuildAggregatedAsync( + """ + { + "commandLineOptions": { + "optional-value": false + }, + "commandLineOptionDefaults": { + "optional-value": "fallback" + } + } + """); + + Assert.IsFalse(configuration.TryGetCommandLineOptionArgumentsOrDefault("optional-value", out string[]? arguments)); + Assert.IsNull(arguments); + } + // --------------------------------------------------------------------- // CommandLineOptionsValidator JSON-aware passes // --------------------------------------------------------------------- @@ -290,6 +335,62 @@ public async Task Validator_JsonPerArgValidatorFailure_Fails() Assert.Contains("testconfig.json", result.ErrorMessage); } + [TestMethod] + public async Task Validator_JsonDefaultPerArgValidatorFailure_FailsWithDefaultsPrefix() + { + ICommandLineOptionsProvider provider = new TestProvider( + new CommandLineOption("timeout", "desc", ArgumentArity.ExactlyOne, isHidden: false), + validateOptionArgumentsAsync: (option, args) => + Task.FromResult(args[0] == "bad" ? ValidationResult.Invalid("bad value") : ValidationResult.Valid())); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + CommandLineParseResult.Empty, + [provider], + [], + new Mock().Object, + jsonCommandLineOptionDefaults: [new JsonCommandLineOptionEntry("timeout", ["bad"], isDisabled: false)]); + + Assert.IsFalse(result.IsValid); + Assert.Contains("bad value", result.ErrorMessage); + Assert.Contains("commandLineOptionDefaults", result.ErrorMessage); + } + + [TestMethod] + public async Task Validator_JsonDefaultForZeroArityOption_Fails() + { + ICommandLineOptionsProvider provider = new TestProvider( + new CommandLineOption("no-banner", "desc", ArgumentArity.Zero, isHidden: false)); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + CommandLineParseResult.Empty, + [provider], + [], + new Mock().Object, + jsonCommandLineOptionDefaults: [new JsonCommandLineOptionEntry("no-banner", ["true"], isDisabled: false)]); + + Assert.IsFalse(result.IsValid); + Assert.Contains("commandLineOptionDefaults", result.ErrorMessage); + Assert.Contains("no-banner", result.ErrorMessage); + } + + [TestMethod] + public async Task Validator_UnknownJsonDefault_Fails() + { + ICommandLineOptionsProvider provider = new TestProvider( + new CommandLineOption("timeout", "desc", ArgumentArity.ExactlyOne, isHidden: false)); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + CommandLineParseResult.Empty, + [provider], + [], + new Mock().Object, + jsonCommandLineOptionDefaults: [new JsonCommandLineOptionEntry("timoeut", ["30s"], isDisabled: false)]); + + Assert.IsFalse(result.IsValid); + Assert.Contains("commandLineOptionDefaults", result.ErrorMessage); + Assert.Contains("timoeut", result.ErrorMessage); + } + [TestMethod] public async Task Validator_JsonArityFailure_DoesNotInvokePerArgValidator() { @@ -409,6 +510,28 @@ public async Task Validator_JsonBootstrapOnlyOption_CaseInsensitive_Fails() Assert.Contains("testconfig.json", result.ErrorMessage); } + [TestMethod] + public async Task Validator_JsonDefaultBootstrapOnlyOption_Fails() + { + ICommandLineOptionsProvider provider = new TestProvider( + new CommandLineOption(PlatformCommandLineProvider.ConfigFileOptionKey, "desc", ArgumentArity.ExactlyOne, isHidden: false)); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + CommandLineParseResult.Empty, + [provider], + [], + new Mock().Object, + jsonCommandLineOptionDefaults: + [ + new JsonCommandLineOptionEntry(PlatformCommandLineProvider.ConfigFileOptionKey, ["other.json"], isDisabled: false), + ]); + + Assert.IsFalse(result.IsValid); + Assert.Contains(PlatformCommandLineProvider.ConfigFileOptionKey, result.ErrorMessage); + Assert.Contains("commandLineOptionDefaults", result.ErrorMessage); + Assert.Contains("bootstrap", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + [TestMethod] public async Task Validator_JsonBootstrapOnlyOption_DisabledEntry_StillFails() { From e2ea1b4c291907861f9be409b65a7d4a99fe8889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 1 Sep 2026 11:31:39 +0200 Subject: [PATCH 2/5] Address option defaults review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f86614b5-b40f-415d-8f94-a3290fbe7154 --- ...Microsoft.Testing.Platform.MSBuild.targets | 27 ++++++++- .../CommandLine/CommandLineOptionsProxy.cs | 4 +- .../CommandLine/ICommandLineOptions.cs | 1 + ...onfigurationProvider.CommandLineOptions.cs | 60 +++++++++++++++---- .../Resources/PlatformResources.resx | 4 ++ .../Resources/xlf/PlatformResources.cs.xlf | 5 ++ .../Resources/xlf/PlatformResources.de.xlf | 5 ++ .../Resources/xlf/PlatformResources.es.xlf | 5 ++ .../Resources/xlf/PlatformResources.fr.xlf | 5 ++ .../Resources/xlf/PlatformResources.it.xlf | 5 ++ .../Resources/xlf/PlatformResources.ja.xlf | 5 ++ .../Resources/xlf/PlatformResources.ko.xlf | 5 ++ .../Resources/xlf/PlatformResources.pl.xlf | 5 ++ .../Resources/xlf/PlatformResources.pt-BR.xlf | 5 ++ .../Resources/xlf/PlatformResources.ru.xlf | 5 ++ .../Resources/xlf/PlatformResources.tr.xlf | 5 ++ .../xlf/PlatformResources.zh-Hans.xlf | 5 ++ .../xlf/PlatformResources.zh-Hant.xlf | 5 ++ .../ServerMode/JsonRpc/Json/Jsonite/Json.cs | 6 +- .../JsonRpc/Json/Jsonite/JsonReader.cs | 15 ++++- .../JsonRpc/Json/Jsonite/JsonReflector.cs | 13 +++- .../MSBuildTests.ConfigurationFile.cs | 57 ++++++++++++++++++ .../CommandLine/CommandLineHandlerTests.cs | 9 +++ .../JsonCommandLineOptionsTests.cs | 15 +++++ 24 files changed, 257 insertions(+), 19 deletions(-) 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 9389fce119..e59bdf5be0 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 @@ -45,10 +45,33 @@ + DependsOnTargets="_CalculateGenerateTestingPlatformConfigurationFile;_GenerateTestingPlatformConfigurationFileInputCache;_GenerateTestingPlatformConfigurationFileCore" /> + + + + <_GenerateTestingPlatformConfigurationFileInputCachePath>$(IntermediateOutputPath)$(MSBuildProjectName).gentestingplatformconfigurationinputcache.cache + <_GenerateTestingPlatformConfigurationFileInputCachePath>$([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(_GenerateTestingPlatformConfigurationFileInputCachePath))) + + + <_GenerateTestingPlatformConfigurationFileInputsToHash Include="@(TestingPlatformCommandLineOptionDefault->'%(Identity)=%(Value)')" /> + + + + + + + + + + _commandLineOptions is null ? throw new InvalidOperationException(Resources.PlatformResources.CommandLineOptionsNotReady) - : _commandLineOptions.TryGetOptionArgumentListOrDefault(optionName, out arguments); + : _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/ICommandLineOptions.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs index 8fb47b7ec0..55a15701f3 100644 --- a/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/ICommandLineOptions.cs @@ -48,6 +48,7 @@ public static bool TryGetOptionArgumentListOrDefault( [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) diff --git a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs index afe337c7c8..0d1f35375e 100644 --- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs @@ -33,21 +33,31 @@ 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. Empty objects + /// are rejected for both sections. /// /// internal IReadOnlyList EnumerateCommandLineOptions() - => EnumerateCommandLineOptionEntries(PlatformConfigurationConstants.CommandLineOptionsSectionName, allowBooleanMarkers: true); + => 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); - - private IReadOnlyList EnumerateCommandLineOptionEntries(string sectionName, bool allowBooleanMarkers) + => EnumerateCommandLineOptionEntries( + PlatformConfigurationConstants.CommandLineOptionDefaultsSectionName, + allowBooleanMarkers: false, + requireValue: true); + + private IReadOnlyList EnumerateCommandLineOptionEntries( + string sectionName, + bool allowBooleanMarkers, + bool requireValue) { Dictionary singleValueData = _singleValueData ?? []; Dictionary propertyToAllChildren = _propertyToAllChildren ?? []; @@ -118,20 +128,28 @@ private IReadOnlyList EnumerateCommandLineOptionEntr 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; } @@ -243,5 +261,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/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx index d26d5eefc3..5282f45175 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -270,6 +270,10 @@ In testconfig.json under 'commandLineOptionDefaults': {0} {0} is the underlying validation error message (unknown option, arity mismatch, invalid arguments...). {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + The option '--{0}' is read during platform bootstrap (before the testconfig.json file is loaded) and must therefore be passed on the command line rather than declared under '{1}' in testconfig.json. {0} is the bare bootstrap-only option name without the leading "--" (e.g. config-file, diagnostic, diagnostic-verbosity); the "--" prefix is already in the format string. {1} is the JSON section name. {Locked="--"}{Locked="testconfig.json"} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index 7529314f34..5f140fb96c 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -644,6 +644,11 @@ Neplatné argumenty příkazového řádku: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index 2aa17224e8..b07f266bf1 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -644,6 +644,11 @@ Ungültige Befehlszeilenargumente: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index 10dc574286..6f79490e4e 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -644,6 +644,11 @@ Argumentos de línea de comandos no válidos: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index 50a13085f5..6a2daae734 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -644,6 +644,11 @@ Arguments de ligne de commande non valides : + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index fdc6387b9f..26135b8b88 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -644,6 +644,11 @@ Argomenti della riga di comando non validi: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 9a82bbf425..a82d2531b2 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -644,6 +644,11 @@ コマンド ラインの引数が無効です: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 7c298e024d..48d7db767a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -644,6 +644,11 @@ 잘못된 명령줄 인수: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index 17d2fa7d45..1ac7fad3bd 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -644,6 +644,11 @@ Nieprawidłowe argumenty wiersza polecenia: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index a2533e1f02..4700de3d57 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -644,6 +644,11 @@ Argumentos de linha de comando inválidos: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index fb22aca7d1..e18a64e5a7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -644,6 +644,11 @@ Недопустимые аргументы командной строки: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index c54137dbcb..3fb37c24d1 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -644,6 +644,11 @@ Geçersiz komut satırı bağımsız değişkenleri: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index deb9a9bded..f1fc24bba8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -644,6 +644,11 @@ 无效的命令行参数: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index 9a0f41a467..d1cc2a86ec 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -644,6 +644,11 @@ 命令列引數無效: + + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + The entry '{0}' under section '{1}' in the testconfig.json file ('{2}') must provide a non-null scalar value or a non-empty array of scalar values. + {0} is the option name. {1} is the section name. {2} is the path to the testconfig.json file. {Locked="testconfig.json"}{Locked="commandLineOptionDefaults"} + In testconfig.json under 'commandLineOptionDefaults': {0} In testconfig.json under 'commandLineOptionDefaults': {0} diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/Json.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/Json.cs index 0f510614fd..a05cd00db1 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/Json.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/Json.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. // Copyright(c) 2016, Alexandre Mutel @@ -81,7 +81,11 @@ public static object Deserialize(TextReader reader, JsonSettings settings = null if (reader == null) throw new ArgumentNullException(nameof(reader)); var parser = new JsonReader(reader, settings ?? DefaultSettings); +#if MTP_MSBUILD_TASKS + return parser.ParseDocument(null, typeof(object), false); +#else return parser.Parse(null, typeof(object), false); +#endif } /// diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReader.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReader.cs index d91df0e19e..53c2a4329d 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReader.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReader.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. // Copyright(c) 2016, Alexandre Mutel @@ -125,6 +125,19 @@ public object Parse(object existingObject, Type expectedType, bool expectValue) return null; } +#if MTP_MSBUILD_TASKS + public object ParseDocument(object existingObject, Type expectedType, bool expectValue) + { + object result = Parse(existingObject, expectedType, expectValue); + if (c != Eof) + { + RaiseUnexpected("after the end of the JSON document"); + } + + return result; + } +#endif + private void IncrementLevel() { level++; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReflector.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReflector.cs index 4966e008e9..88d55e3280 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReflector.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Jsonite/JsonReflector.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. // Copyright(c) 2016, Alexandre Mutel @@ -317,7 +317,18 @@ public void OnDeserializePrepareMemberForObject(object objectContext, object obj public void OnDeserializeSetObjectMember(object objectContext, object target, object memberContext, object value) { +#if MTP_MSBUILD_TASKS + var dictionary = (IDictionary)target; + string memberName = (string)memberContext; + if (dictionary.ContainsKey(memberName)) + { + throw new JsonException(0, 0, 0, $"Duplicate JSON property '{memberName}'."); + } + + dictionary.Add(memberName, value); +#else ((IDictionary)target)[(string)memberContext] = value; +#endif } public object OnDeserializeExitObject(object objectContext, object obj) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs index edb02e2ca1..88cc49a508 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs @@ -114,6 +114,17 @@ public async Task ConfigFileGeneration_OptionDefaultsGenerateConfigurationWithou ["first", "second"], defaults.GetProperty("filter-uid").EnumerateArray().Select(x => x.GetString()).ToArray()); + DotnetMuxerResult unchangedBuildResult = await DotnetCli.RunAsync( + $"build -v:normal {testAsset.TargetAssetPath} -c Release", + cancellationToken: TestContext.CancellationToken); + + Assert.IsTrue(Regex.IsMatch( + unchangedBuildResult.StandardOutput, + """ + \s*_GenerateTestingPlatformConfigurationFileCore: + \s*Skipping target "_GenerateTestingPlatformConfigurationFileCore" because all output files are up\-to\-date with respect to the input files\. + """)); + await DotnetCli.RunAsync( $"build -v:normal {testAsset.TargetAssetPath} -c Release /p:TrxFileName=changed.trx", cancellationToken: TestContext.CancellationToken); @@ -124,6 +135,26 @@ await DotnetCli.RunAsync( updatedDocument.RootElement.GetProperty("commandLineOptionDefaults").GetProperty("report-trx-filename").GetString()); } + [TestMethod] + [DataRow("duplicate", """{"value": 1, "value": 2}""")] + [DataRow("trailing-content", """{} trailing""")] + public async Task ConfigFileGeneration_PackagedTaskRejectsInvalidJson(string scenario, string json) + { + using TestAsset testAsset = await TestAsset.GenerateAssetAsync( + $"{nameof(ConfigFileGeneration_PackagedTaskRejectsInvalidJson)}_{scenario}", + InvalidJsonOptionDefaultsSourceCode + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion) + .PatchCodeWithReplace("$JsonContent$", json)); + + DotnetMuxerResult result = await DotnetCli.RunAsync( + $"build -v:normal {testAsset.TargetAssetPath} -c Release", + failIfReturnValueIsNotZero: false, + cancellationToken: TestContext.CancellationToken); + + result.AssertExitCodeIsNot(0); + result.AssertOutputContains("Failed to parse Microsoft Testing Platform configuration file"); + } + private const string ConfigurationContent = """ { "platformOptions": { @@ -221,5 +252,31 @@ public static void Main() } """; + private const string InvalidJsonOptionDefaultsSourceCode = """ + #file MSBuildInvalidOptionDefaults.csproj + + + net8.0 + Exe + false + + + + + + + + #file testconfig.json + $JsonContent$ + + #file Program.cs + public static class Program + { + public static void Main() + { + } + } + """; + public TestContext TestContext { get; set; } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs index 2bb4292bfa..6c4fcf02a7 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs @@ -1182,6 +1182,15 @@ public void GetOptionValueOrDefault_ExplicitValueWins() Assert.AreSequenceEqual(["explicit-value"], arguments); } + [TestMethod] + public void GetOptionValueOrDefault_NullOptionNameThrows() + { + ICommandLineOptions commandLineOptions = Mock.Of(); + + Assert.ThrowsExactly( + () => commandLineOptions.TryGetOptionArgumentListOrDefault(null!, out _)); + } + private sealed class ExtensionCommandLineProviderMockReservedOptions : ICommandLineOptionsProvider { public const string HelpOption = "help"; diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs index 47f558c503..3a81181e3f 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs @@ -203,6 +203,21 @@ public async Task EnumerateCommandLineOptionDefaults_ScalarsAndArrays_AreArgumen Assert.IsFalse(isSet); } + [TestMethod] + [DataRow("null")] + [DataRow("[]")] + public async Task EnumerateCommandLineOptionDefaults_MissingValue_IsRejected(string value) + { + AggregatedConfiguration configuration = await BuildAggregatedAsync( + "{\"commandLineOptionDefaults\": {\"timeout\": " + value + "}}"); + + FormatException exception = Assert.ThrowsExactly( + configuration.EnumerateJsonCommandLineOptionDefaults); + + Assert.Contains("timeout", exception.Message); + Assert.Contains("non-null scalar value or a non-empty array", exception.Message); + } + [TestMethod] public async Task ExplicitJsonDisable_SuppressesConfiguredDefault() { From 8997ad70445ccf9b3422aac21860d8e5f8d2d0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 1 Sep 2026 12:32:57 +0200 Subject: [PATCH 3/5] Address late option defaults feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f86614b5-b40f-415d-8f94-a3290fbe7154 --- ...Microsoft.Testing.Platform.MSBuild.targets | 2 +- .../ReportEngineBase.cs | 7 ++- .../MSBuildTests.ConfigurationFile.cs | 62 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) 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 e59bdf5be0..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 @@ -49,7 +49,7 @@ + Condition=" '$(GenerateTestingPlatformConfigurationFile)' == 'true' And (Exists('$(_TestingPlatformConfigurationFileSourcePath)') Or '@(TestingPlatformCommandLineOptionDefault)' != '') "> <_GenerateTestingPlatformConfigurationFileInputCachePath>$(IntermediateOutputPath)$(MSBuildProjectName).gentestingplatformconfigurationinputcache.cache <_GenerateTestingPlatformConfigurationFileInputCachePath>$([MSBuild]::NormalizePath($(MSBuildProjectDirectory), $(_GenerateTestingPlatformConfigurationFileInputCachePath))) diff --git a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs b/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs index a90205cc1a..d7922a85d7 100644 --- a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs +++ b/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs @@ -94,8 +94,11 @@ internal static string BuildDefaultFileName(string testApplicationModule, string { _cancellationToken.ThrowIfCancellationRequested(); - bool wasExplicit = _commandLineOptions.TryGetOptionArgumentListOrDefault(fileNameOptionName, out string[]? providedFileName); - string fileName = wasExplicit + // TryGetOptionArgumentListOrDefault also returns true for a passive default coming from + // testconfig.json, so it cannot be used to determine explicitness. Use it only to pick the + // file name, and derive wasExplicit from IsOptionSet to preserve the documented contract. + bool wasExplicit = _commandLineOptions.IsOptionSet(fileNameOptionName); + string fileName = _commandLineOptions.TryGetOptionArgumentListOrDefault(fileNameOptionName, out string[]? providedFileName) ? ResolveProvidedFileName(GetProvidedFileName(providedFileName)) : defaultFileNameFactory(); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs index 88cc49a508..367f1b32e6 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuildTests.ConfigurationFile.cs @@ -155,6 +155,36 @@ public async Task ConfigFileGeneration_PackagedTaskRejectsInvalidJson(string sce result.AssertOutputContains("Failed to parse Microsoft Testing Platform configuration file"); } + [TestMethod] + public async Task ConfigFileGeneration_RemovingAllOptionDefaultsRefreshesConfiguration() + { + using TestAsset testAsset = await TestAsset.GenerateAssetAsync( + nameof(ConfigFileGeneration_RemovingAllOptionDefaultsRefreshesConfiguration), + ConditionalOptionDefaultsSourceCode + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + + await DotnetCli.RunAsync( + $"build -v:normal {testAsset.TargetAssetPath} -c Release", + cancellationToken: TestContext.CancellationToken); + + var testHost = TestInfrastructure.TestHost.LocateFrom(testAsset.TargetAssetPath, "MSBuildConditionalOptionDefaults", "net8.0", buildConfiguration: BuildConfiguration.Release); + string generatedConfigurationFile = Path.Combine(testHost.DirectoryName, "MSBuildConditionalOptionDefaults.testconfig.json"); + using (var document = JsonDocument.Parse(File.ReadAllText(generatedConfigurationFile))) + { + Assert.AreEqual( + "{asm}.trx", + document.RootElement.GetProperty("commandLineOptionDefaults").GetProperty("report-trx-filename").GetString()); + } + + await DotnetCli.RunAsync( + $"build -v:normal {testAsset.TargetAssetPath} -c Release /p:IncludeOptionDefaults=false", + cancellationToken: TestContext.CancellationToken); + + using var updatedDocument = JsonDocument.Parse(File.ReadAllText(generatedConfigurationFile)); + Assert.IsFalse(updatedDocument.RootElement.TryGetProperty("commandLineOptionDefaults", out _)); + Assert.IsTrue(updatedDocument.RootElement.GetProperty("platformOptions").GetProperty("exitProcessOnUnhandledException").GetBoolean()); + } + private const string ConfigurationContent = """ { "platformOptions": { @@ -278,5 +308,37 @@ public static void Main() } """; + private const string ConditionalOptionDefaultsSourceCode = """ + #file MSBuildConditionalOptionDefaults.csproj + + + net8.0 + Exe + false + + + + + + + + #file testconfig.json + { + "platformOptions": { + "exitProcessOnUnhandledException": true + } + } + + #file Program.cs + public static class Program + { + public static void Main() + { + } + } + """; + public TestContext TestContext { get; set; } } From 4c07588a0963669c568a64cac6221cc4283ace1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 1 Sep 2026 13:12:09 +0200 Subject: [PATCH 4/5] Harden default arrays and reporter coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f86614b5-b40f-415d-8f94-a3290fbe7154 --- .../JsonConfigurationFileParser.cs | 19 ++++++- ...JsonConfigurationFileParser.netstandard.cs | 13 +++++ ...onfigurationProvider.CommandLineOptions.cs | 22 +++++++- .../CtrfReportTests.cs | 50 +++++++++++++++++- .../JsonCommandLineOptionsTests.cs | 51 +++++++++++++++++++ 5 files changed, 150 insertions(+), 5 deletions(-) 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 0d1f35375e..016cce08b7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/Configurations/JsonConfigurationProvider.CommandLineOptions.cs @@ -34,8 +34,9 @@ internal sealed partial class JsonConfigurationProvider /// /// /// 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. Empty objects - /// are rejected for both sections. + /// 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() @@ -186,6 +187,23 @@ private IReadOnlyList EnumerateCommandLineOptionEntr 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!; } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CtrfReportTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CtrfReportTests.cs index d2c5329114..38040e4840 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CtrfReportTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/CtrfReportTests.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. namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; @@ -109,6 +109,54 @@ public async Task Ctrf_WhenReportCtrfFilenameContainsPath_CtrfReportIsGeneratedI $"Expected custom CTRF report file '{customFileName}' was not found in '{testResultsPath}'."); } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task Ctrf_CommandLineOptionDefault_IsPassiveAndExplicitValueWins(string tfm) + { + // CtrfReportEngine resolves its output file name through the shared + // ReportEngineBase.ResolveOutputPath, unlike TrxReportEngine which has its own + // ResolveTrxOutputPath. This exercises that shared code path end to end: a + // testconfig.json default is passive (it does not enable the report on its own, + // and it is not treated as an explicit value), and an explicit + // --report-ctrf-filename still wins over the configured default. + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); + using TempDirectory clone = new(); + testHost = await CloneTestHostAsync(testHost, clone, TestAssetFixture.AssetName); + string configFile = Path.Combine(testHost.DirectoryName, $"{TestAssetFixture.AssetName}.testconfig.json"); + await File.WriteAllTextAsync( + configFile, + """ + { + "commandLineOptionDefaults": { + "report-ctrf-filename": "configured-{asm}.ctrf.json" + } + } + """, + TestContext.CancellationToken); + + TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.AtLeastOneTestFailed); + Assert.IsEmpty(Directory.GetFiles(testHost.DirectoryName, "configured-*.ctrf.json", SearchOption.AllDirectories)); + + string defaultResultsPath = Path.Combine(testHost.DirectoryName, "default-results"); + testHostResult = await testHost.ExecuteAsync( + $"--report-ctrf --results-directory \"{defaultResultsPath}\"", + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.AtLeastOneTestFailed); + Assert.IsTrue(File.Exists(Path.Combine(defaultResultsPath, $"configured-{TestAssetFixture.AssetName}.ctrf.json"))); + + string explicitResultsPath = Path.Combine(testHost.DirectoryName, "explicit-results"); + testHostResult = await testHost.ExecuteAsync( + $"--report-ctrf --report-ctrf-filename explicit.ctrf.json --results-directory \"{explicitResultsPath}\"", + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.AtLeastOneTestFailed); + Assert.IsTrue(File.Exists(Path.Combine(explicitResultsPath, "explicit.ctrf.json"))); + Assert.IsFalse(File.Exists(Path.Combine(explicitResultsPath, $"configured-{TestAssetFixture.AssetName}.ctrf.json"))); + } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] [TestMethod] public async Task Ctrf_WhenReportCtrfFilenameIsSpecifiedWithoutReportCtrf_ErrorIsDisplayed(string tfm) diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs index 3a81181e3f..cbc3601eac 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Configuration/JsonCommandLineOptionsTests.cs @@ -218,6 +218,57 @@ public async Task EnumerateCommandLineOptionDefaults_MissingValue_IsRejected(str Assert.Contains("non-null scalar value or a non-empty array", exception.Message); } + [TestMethod] + public async Task EnumerateCommandLineOptionDefaults_ArrayWithNullElement_IsRejected() + { + // A JSON null array element must be rejected even though both JSON parsers (System.Text.Json and, + // on net462/netstandard2.0, Jsonite) flatten it into a non-null placeholder string ("" or "null" + // respectively) that looks like a real scalar. The parser separately preserves the element's raw + // JSON token so this case can be detected consistently across both parsers. + AggregatedConfiguration configuration = await BuildAggregatedAsync( + "{\"commandLineOptionDefaults\": {\"filter-uid\": [null]}}"); + + FormatException exception = Assert.ThrowsExactly( + configuration.EnumerateJsonCommandLineOptionDefaults); + + Assert.Contains("filter-uid", exception.Message); + Assert.Contains("non-null scalar value or a non-empty array", exception.Message); + } + + [TestMethod] + public async Task EnumerateCommandLineOptionDefaults_MixedArrayWithNullElement_IsRejected() + { + // A null element must be rejected even when mixed with otherwise-valid scalar entries in the same + // array, and regardless of its position. + AggregatedConfiguration configuration = await BuildAggregatedAsync( + "{\"commandLineOptionDefaults\": {\"filter-uid\": [\"a\", null, \"b\"]}}"); + + FormatException exception = Assert.ThrowsExactly( + configuration.EnumerateJsonCommandLineOptionDefaults); + + Assert.Contains("filter-uid", exception.Message); + Assert.Contains("non-null scalar value or a non-empty array", exception.Message); + } + + [TestMethod] + public async Task EnumerateCommandLineOptions_ArrayWithNullElement_PreservesEstablishedBehavior() + { + // Unlike commandLineOptionDefaults, commandLineOptions does not require every array element to be + // non-null; this locks in the pre-existing (per-JSON-parser) flattening of a null element so this + // fix does not change it. System.Text.Json flattens a null array element to an empty string, while + // the Jsonite parser (net462/netstandard2.0) flattens it to the literal "null" string. + IReadOnlyList entries = await EnumerateAsync( + "{\"commandLineOptions\": {\"filter-uid\": [\"a\", null, \"b\"]}}"); + + JsonCommandLineOptionEntry entry = Assert.ContainsSingle(entries); + Assert.AreEqual("filter-uid", entry.OptionName); +#if NETFRAMEWORK + Assert.AreSequenceEqual(["a", "null", "b"], entry.Arguments.ToArray()); +#else + Assert.AreSequenceEqual(["a", string.Empty, "b"], entry.Arguments.ToArray()); +#endif + } + [TestMethod] public async Task ExplicitJsonDisable_SuppressesConfiguredDefault() { From 634d8e5863f6370b3396e828a6fbacd405ae43e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 1 Sep 2026 13:24:33 +0200 Subject: [PATCH 5/5] Strengthen duplicate configuration test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f86614b5-b40f-415d-8f94-a3290fbe7154 --- .../MSBuildTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs index 735b758e73..2b9de6f186 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/MSBuildTests.cs @@ -243,6 +243,7 @@ public void ConfigurationFileTask_ReportsDuplicateJsonKeys() { string projectDirectory = Path.Combine("root", "project"); string sourcePath = Path.Combine(projectDirectory, "testconfig.json"); + string outputPath = Path.Combine(projectDirectory, "bin", "Tests.testconfig.json"); InMemoryFileSystem fileSystem = new(); fileSystem.Files[sourcePath] = """ @@ -259,6 +260,7 @@ public void ConfigurationFileTask_ReportsDuplicateJsonKeys() Assert.IsFalse(task.Execute()); Assert.Contains("duplicate keys", Assert.ContainsSingle(_errors).Message ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.IsFalse(fileSystem.Files.ContainsKey(outputPath)); } private ConfigurationFileTask CreateConfigurationFileTask(InMemoryFileSystem fileSystem, string projectDirectory)