Use registered option<T> factories instead of activator based construction (#2684)

* Use registered option<T> factories instead of activator based construction

* Allow case-insensitive enum validation

* Add success message to smoke test in Build-Code.ps1

* Add full array support and tests

* Enhance smoke test logging for Build-Code.ps1 (#2689)

Enhance smoke test logging in Build-Code.ps1 to capture stdout and stderr on failure

---------

Co-authored-by: vcolin7 <victor.y.asi@gmail.com>
This commit is contained in:
Patrick Halliseyandvcolin7 authored and GitHub committed 2026-05-20 13:54:34 -07:00
1 parent 566057f65a
commit 37c9638b80
5 files changed
+746 -40

No files matched your search

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.CommandLine.Parsing;
namespace Microsoft.Mcp.Core.Options;
/// <summary>
/// Provides case-insensitive validation and completion for enum options represented as Option&lt;string&gt;.
/// </summary>
internal static class EnumOptionValidator
{
/// <summary>
/// Configures an option with case-insensitive enum validation and completion sources.
/// Mirrors the behavior of AcceptOnlyFromAmong but with case-insensitive matching.
/// </summary>
public static void Configure(Option<string> option, Type enumType)
{
var names = Enum.GetNames(enumType);
var allowed = string.Join(", ", names);
var namesSet = new HashSet<string>(names, StringComparer.OrdinalIgnoreCase);
option.Validators.Add(result =>
{
foreach (Token token in result.Tokens)
{
if (!namesSet.Contains(token.Value))
{
result.AddError($"Argument '{token.Value}' not recognized. Must be one of: {allowed}");
}
}
});
option.CompletionSources.Add(names);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Collections.Concurrent;
using System.CommandLine.Parsing;
using System.Diagnostics.CodeAnalysis;
using System.Net;
@@ -16,21 +17,107 @@ namespace Microsoft.Mcp.Core.Options;
/// </summary>
public static class OptionBinder
{
private static readonly MethodInfo GetValueOrDefaultMethod =
typeof(ParseResultExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(m => m.Name == nameof(ParseResultExtensions.GetValueOrDefault)
&& m.IsGenericMethodDefinition
&& m.GetParameters().Length == 2
&& m.GetParameters()[1].ParameterType == typeof(string));
/// <summary>
/// To prevent native AOT builds from trimming away the filled generic Options<T> methods, we have to maintain a
/// centralized factory pattern. Each entry provides both the option factory (for registration) and value binder
/// (for parsing). If a type is not in this dictionary and is not an enum, it is unsupported and will be rejected
/// at option registration time.
/// </summary>
private static readonly ConcurrentDictionary<Type, OptionTypeHandler> s_typeHandlers = new()
{
// String
[typeof(string)] = new(name => new Option<string>(name), (pr, n) => pr.GetValueOrDefault<string>(n)),
[typeof(string[])] = new(name => new Option<string[]>(name), (pr, n) => pr.GetValueOrDefault<string[]>(n)),
// Boolean
[typeof(bool)] = new(name => new Option<bool>(name), (pr, n) => pr.GetValueOrDefault<bool>(n)),
[typeof(bool?)] = new(name => new Option<bool?>(name), (pr, n) => pr.GetValueOrDefault<bool?>(n)),
[typeof(bool[])] = new(name => new Option<bool[]>(name), (pr, n) => pr.GetValueOrDefault<bool[]>(n)),
// Int
[typeof(int)] = new(name => new Option<int>(name), (pr, n) => pr.GetValueOrDefault<int>(n)),
[typeof(int?)] = new(name => new Option<int?>(name), (pr, n) => pr.GetValueOrDefault<int?>(n)),
[typeof(int[])] = new(name => new Option<int[]>(name), (pr, n) => pr.GetValueOrDefault<int[]>(n)),
// Long
[typeof(long)] = new(name => new Option<long>(name), (pr, n) => pr.GetValueOrDefault<long>(n)),
[typeof(long?)] = new(name => new Option<long?>(name), (pr, n) => pr.GetValueOrDefault<long?>(n)),
[typeof(long[])] = new(name => new Option<long[]>(name), (pr, n) => pr.GetValueOrDefault<long[]>(n)),
// Short
[typeof(short)] = new(name => new Option<short>(name), (pr, n) => pr.GetValueOrDefault<short>(n)),
[typeof(short?)] = new(name => new Option<short?>(name), (pr, n) => pr.GetValueOrDefault<short?>(n)),
[typeof(short[])] = new(name => new Option<short[]>(name), (pr, n) => pr.GetValueOrDefault<short[]>(n)),
// Byte
[typeof(byte)] = new(name => new Option<byte>(name), (pr, n) => pr.GetValueOrDefault<byte>(n)),
[typeof(byte?)] = new(name => new Option<byte?>(name), (pr, n) => pr.GetValueOrDefault<byte?>(n)),
[typeof(byte[])] = new(name => new Option<byte[]>(name), (pr, n) => pr.GetValueOrDefault<byte[]>(n)),
// SByte
[typeof(sbyte)] = new(name => new Option<sbyte>(name), (pr, n) => pr.GetValueOrDefault<sbyte>(n)),
[typeof(sbyte?)] = new(name => new Option<sbyte?>(name), (pr, n) => pr.GetValueOrDefault<sbyte?>(n)),
[typeof(sbyte[])] = new(name => new Option<sbyte[]>(name), (pr, n) => pr.GetValueOrDefault<sbyte[]>(n)),
// UShort
[typeof(ushort)] = new(name => new Option<ushort>(name), (pr, n) => pr.GetValueOrDefault<ushort>(n)),
[typeof(ushort?)] = new(name => new Option<ushort?>(name), (pr, n) => pr.GetValueOrDefault<ushort?>(n)),
[typeof(ushort[])] = new(name => new Option<ushort[]>(name), (pr, n) => pr.GetValueOrDefault<ushort[]>(n)),
// UInt
[typeof(uint)] = new(name => new Option<uint>(name), (pr, n) => pr.GetValueOrDefault<uint>(n)),
[typeof(uint?)] = new(name => new Option<uint?>(name), (pr, n) => pr.GetValueOrDefault<uint?>(n)),
[typeof(uint[])] = new(name => new Option<uint[]>(name), (pr, n) => pr.GetValueOrDefault<uint[]>(n)),
// ULong
[typeof(ulong)] = new(name => new Option<ulong>(name), (pr, n) => pr.GetValueOrDefault<ulong>(n)),
[typeof(ulong?)] = new(name => new Option<ulong?>(name), (pr, n) => pr.GetValueOrDefault<ulong?>(n)),
[typeof(ulong[])] = new(name => new Option<ulong[]>(name), (pr, n) => pr.GetValueOrDefault<ulong[]>(n)),
// Float
[typeof(float)] = new(name => new Option<float>(name), (pr, n) => pr.GetValueOrDefault<float>(n)),
[typeof(float?)] = new(name => new Option<float?>(name), (pr, n) => pr.GetValueOrDefault<float?>(n)),
[typeof(float[])] = new(name => new Option<float[]>(name), (pr, n) => pr.GetValueOrDefault<float[]>(n)),
// Double
[typeof(double)] = new(name => new Option<double>(name), (pr, n) => pr.GetValueOrDefault<double>(n)),
[typeof(double?)] = new(name => new Option<double?>(name), (pr, n) => pr.GetValueOrDefault<double?>(n)),
[typeof(double[])] = new(name => new Option<double[]>(name), (pr, n) => pr.GetValueOrDefault<double[]>(n)),
// Decimal
[typeof(decimal)] = new(name => new Option<decimal>(name), (pr, n) => pr.GetValueOrDefault<decimal>(n)),
[typeof(decimal?)] = new(name => new Option<decimal?>(name), (pr, n) => pr.GetValueOrDefault<decimal?>(n)),
[typeof(decimal[])] = new(name => new Option<decimal[]>(name), (pr, n) => pr.GetValueOrDefault<decimal[]>(n)),
// Char
[typeof(char)] = new(name => new Option<char>(name), (pr, n) => pr.GetValueOrDefault<char>(n)),
[typeof(char?)] = new(name => new Option<char?>(name), (pr, n) => pr.GetValueOrDefault<char?>(n)),
[typeof(char[])] = new(name => new Option<char[]>(name), (pr, n) => pr.GetValueOrDefault<char[]>(n)),
// DateTime
[typeof(DateTime)] = new(name => new Option<DateTime>(name), (pr, n) => pr.GetValueOrDefault<DateTime>(n)),
[typeof(DateTime?)] = new(name => new Option<DateTime?>(name), (pr, n) => pr.GetValueOrDefault<DateTime?>(n)),
[typeof(DateTime[])] = new(name => new Option<DateTime[]>(name), (pr, n) => pr.GetValueOrDefault<DateTime[]>(n)),
// DateTimeOffset
[typeof(DateTimeOffset)] = new(name => new Option<DateTimeOffset>(name), (pr, n) => pr.GetValueOrDefault<DateTimeOffset>(n)),
[typeof(DateTimeOffset?)] = new(name => new Option<DateTimeOffset?>(name), (pr, n) => pr.GetValueOrDefault<DateTimeOffset?>(n)),
[typeof(DateTimeOffset[])] = new(name => new Option<DateTimeOffset[]>(name), (pr, n) => pr.GetValueOrDefault<DateTimeOffset[]>(n)),
// TimeSpan
[typeof(TimeSpan)] = new(name => new Option<TimeSpan>(name), (pr, n) => pr.GetValueOrDefault<TimeSpan>(n)),
[typeof(TimeSpan?)] = new(name => new Option<TimeSpan?>(name), (pr, n) => pr.GetValueOrDefault<TimeSpan?>(n)),
[typeof(TimeSpan[])] = new(name => new Option<TimeSpan[]>(name), (pr, n) => pr.GetValueOrDefault<TimeSpan[]>(n)),
// Guid
[typeof(Guid)] = new(name => new Option<Guid>(name), (pr, n) => pr.GetValueOrDefault<Guid>(n)),
[typeof(Guid?)] = new(name => new Option<Guid?>(name), (pr, n) => pr.GetValueOrDefault<Guid?>(n)),
[typeof(Guid[])] = new(name => new Option<Guid[]>(name), (pr, n) => pr.GetValueOrDefault<Guid[]>(n)),
};
/// <summary>
/// Registers System.CommandLine options on a command based on the public properties of <typeparamref name="TOptions"/>.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2055:MakeGenericType",
Justification = "Option<T> is used with property types rooted by the application.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode",
Justification = "Option<T> generic instantiation uses types rooted by the application.")]
public static void RegisterOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TOptions>(Command command)
where TOptions : class
{
@@ -46,10 +133,6 @@ public static class OptionBinder
/// Creates a new <typeparamref name="TOptions"/> instance and populates its properties
/// from the parsed command-line values.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2060:MakeGenericMethod",
Justification = "GetValueOrDefault<T> is called with property types rooted by the application.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode",
Justification = "Generic method instantiation uses types rooted by the application.")]
public static TOptions BindOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TOptions>(ParseResult parseResult)
where TOptions : class
{
@@ -62,16 +145,15 @@ public static class OptionBinder
foreach (var descriptor in descriptors)
{
var optionName = $"--{descriptor.Name}";
var method = GetValueOrDefaultMethod.MakeGenericMethod(descriptor.Type);
object? value;
try
{
value = method.Invoke(null, [parseResult, optionName]);
value = GetOptionValue(parseResult, descriptor.Type, optionName);
}
catch (TargetInvocationException ex) when (ex.InnerException is InvalidOperationException or FormatException or OverflowException)
catch (Exception ex) when (ex is InvalidOperationException or FormatException or OverflowException or ArgumentException)
{
errors.Add($"Invalid value for '{optionName}': {ex.InnerException.Message}");
errors.Add($"Invalid value for '{optionName}': {ex.Message}");
continue;
}
@@ -130,14 +212,11 @@ public static class OptionBinder
return instance;
}
[UnconditionalSuppressMessage("Trimming", "IL2055:MakeGenericType",
Justification = "Option<T> is used with property types rooted by the application.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode",
Justification = "Option<T> generic instantiation uses types rooted by the application.")]
private static Option CreateOption(OptionDescriptor descriptor)
{
var optionType = typeof(Option<>).MakeGenericType(descriptor.Type);
var option = (Option)Activator.CreateInstance(optionType, $"--{descriptor.Name}")!;
var name = $"--{descriptor.Name}";
var handler = GetHandler(descriptor.Type);
var option = handler.CreateOption(name);
option.Description = descriptor.Description;
option.Required = descriptor.Required;
option.Hidden = descriptor.Hidden;
@@ -153,6 +232,64 @@ public static class OptionBinder
return option;
}
private static object? GetOptionValue(ParseResult parseResult, Type type, string optionName)
{
var handler = GetHandler(type);
return handler.GetValue(parseResult, optionName);
}
private static OptionTypeHandler GetHandler(Type type)
{
if (s_typeHandlers.TryGetValue(type, out var handler))
{
return handler;
}
// Enums (and Nullable<enum>): represented as Option<string> with constrained values
Type? underlyingEnum = GetUnderlyingEnumType(type);
if (underlyingEnum is not null)
{
return s_typeHandlers.GetOrAdd(type, _ => new OptionTypeHandler(
name =>
{
var option = new Option<string>(name);
EnumOptionValidator.Configure(option, underlyingEnum);
return option;
},
(pr, n) =>
{
var stringValue = pr.GetValueOrDefault<string>(n);
if (stringValue is null)
{
return null;
}
return Enum.Parse(underlyingEnum, stringValue, ignoreCase: true);
}));
}
throw new InvalidOperationException(
$"Unsupported option type '{type}'. Add a handler to s_typeHandlers in OptionBinder, or override RegisterOptions/BindOptions in the command.");
}
private static Type? GetUnderlyingEnumType(Type type)
{
if (type.IsEnum)
{
return type;
}
Type? nullable = Nullable.GetUnderlyingType(type);
if (nullable is not null && nullable.IsEnum)
{
return nullable;
}
return null;
}
[UnconditionalSuppressMessage("Trimming", "IL2067:UnrecognizedReflectionPattern",
Justification = "Nested option types are rooted by the application via property references.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode",
@@ -162,4 +299,12 @@ public static class OptionBinder
return Activator.CreateInstance(type)
?? throw new InvalidOperationException($"Failed to create instance of nested options type '{type.Name}'. Ensure it has a public parameterless constructor.");
}
private sealed class OptionTypeHandler(
Func<string, Option> createOption,
Func<ParseResult, string, object?> getValue)
{
public Option CreateOption(string name) => createOption(name);
public object? GetValue(ParseResult parseResult, string optionName) => getValue(parseResult, optionName);
}
}
@@ -131,8 +131,7 @@ public class OptionDescriptor
underlying == typeof(DateTime) ||
underlying == typeof(DateTimeOffset) ||
underlying == typeof(TimeSpan) ||
underlying == typeof(Guid) ||
underlying == typeof(Uri);
underlying == typeof(Guid);
}
[UnconditionalSuppressMessage("Trimming", "IL2070:UnrecognizedReflectionPattern",
@@ -0,0 +1,480 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.Mcp.Core.Options;
using Xunit;
namespace Microsoft.Mcp.Core.Tests.Options;
public sealed class OptionBinderTests
{
#region Test Options Classes
private sealed class StringOptions
{
public string? Name { get; set; }
public string? Description { get; set; }
}
private sealed class IntOptions
{
public int Count { get; set; }
public int? Limit { get; set; }
}
private sealed class BoolOptions
{
public bool Verbose { get; set; }
public bool? Debug { get; set; }
}
private sealed class ArrayOptions
{
public string[]? Tags { get; set; }
public int[]? Ports { get; set; }
}
private enum Color
{
Red,
Green,
Blue,
}
private sealed class EnumOptions
{
public Color Color { get; set; }
public Color? Background { get; set; }
}
private sealed class GuidOptions
{
public Guid Id { get; set; }
public Guid? CorrelationId { get; set; }
}
private sealed class DateTimeOptions
{
public DateTime StartDate { get; set; }
public DateTimeOffset? Timestamp { get; set; }
}
private sealed class DecimalOptions
{
public decimal Price { get; set; }
public double? Rate { get; set; }
}
private sealed class UnsupportedTypeOptions
{
public object[]? Value { get; set; }
}
private sealed class NetworkSettings
{
public string? Host { get; set; }
public int? Port { get; set; }
}
private sealed class RequiredNetworkSettings
{
public required string Name { get; set; }
public int? Port { get; set; }
}
private sealed class NestedOptional
{
public string? Name { get; set; }
public NetworkSettings? Optional { get; set; }
public required NetworkSettings Required { get; set; }
}
private sealed class NestedRequired
{
public string? Name { get; set; }
public required RequiredNetworkSettings Required { get; set; }
}
#endregion
#region RegisterOptions Tests
[Fact]
public void RegisterOptions_String_RegistersCorrectOptions()
{
var command = new Command("test");
OptionBinder.RegisterOptions<StringOptions>(command);
Assert.Equal(2, command.Options.Count);
Assert.Contains(command.Options, o => o.Name == "--name");
Assert.Contains(command.Options, o => o.Name == "--description");
}
[Fact]
public void RegisterOptions_Int_RegistersCorrectOptions()
{
var command = new Command("test");
OptionBinder.RegisterOptions<IntOptions>(command);
Assert.Equal(2, command.Options.Count);
Assert.Contains(command.Options, o => o.Name == "--count");
Assert.Contains(command.Options, o => o.Name == "--limit");
}
[Fact]
public void RegisterOptions_Array_SetsArityToOneOrMore()
{
var command = new Command("test");
OptionBinder.RegisterOptions<ArrayOptions>(command);
var tagsOption = command.Options.Single(o => o.Name == "--tags");
Assert.Equal(ArgumentArity.OneOrMore, tagsOption.Arity);
Assert.True(tagsOption.AllowMultipleArgumentsPerToken);
}
[Fact]
public void RegisterOptions_Enum_RegistersAsStringOption()
{
var command = new Command("test");
OptionBinder.RegisterOptions<EnumOptions>(command);
Assert.Equal(2, command.Options.Count);
Assert.Contains(command.Options, o => o.Name == "--color");
Assert.Contains(command.Options, o => o.Name == "--background");
}
[Fact]
public void RegisterOptions_UnsupportedType_Throws()
{
var command = new Command("test");
var ex = Assert.Throws<InvalidOperationException>(
() => OptionBinder.RegisterOptions<UnsupportedTypeOptions>(command));
Assert.Contains("non-scalar element type", ex.Message);
}
#endregion
#region BindOptions Tests
[Fact]
public void BindOptions_String_BindsValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<StringOptions>(command);
var parseResult = command.Parse("--name hello --description world");
var options = OptionBinder.BindOptions<StringOptions>(parseResult);
Assert.Equal("hello", options.Name);
Assert.Equal("world", options.Description);
}
[Fact]
public void BindOptions_String_NullWhenNotProvided()
{
var command = new Command("test");
OptionBinder.RegisterOptions<StringOptions>(command);
var parseResult = command.Parse("");
var options = OptionBinder.BindOptions<StringOptions>(parseResult);
Assert.Null(options.Name);
Assert.Null(options.Description);
}
[Fact]
public void BindOptions_Int_BindsValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<IntOptions>(command);
var parseResult = command.Parse("--count 42 --limit 100");
var options = OptionBinder.BindOptions<IntOptions>(parseResult);
Assert.Equal(42, options.Count);
Assert.Equal(100, options.Limit);
}
[Fact]
public void BindOptions_Bool_BindsValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<BoolOptions>(command);
var parseResult = command.Parse("--verbose true --debug false");
var options = OptionBinder.BindOptions<BoolOptions>(parseResult);
Assert.True(options.Verbose);
Assert.Equal(false, options.Debug);
}
[Fact]
public void BindOptions_Array_BindsMultipleValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<ArrayOptions>(command);
var parseResult = command.Parse("--tags foo bar baz --ports 80 443");
var options = OptionBinder.BindOptions<ArrayOptions>(parseResult);
Assert.Equal(["foo", "bar", "baz"], options.Tags!);
Assert.Equal([80, 443], options.Ports!);
}
[Fact]
public void BindOptions_Enum_BindsCaseInsensitive()
{
var command = new Command("test");
OptionBinder.RegisterOptions<EnumOptions>(command);
var parseResult = command.Parse("--color green --background BLUE");
var options = OptionBinder.BindOptions<EnumOptions>(parseResult);
Assert.Equal(Color.Green, options.Color);
Assert.Equal(Color.Blue, options.Background);
}
[Fact]
public void BindOptions_Enum_MixedCase()
{
var command = new Command("test");
OptionBinder.RegisterOptions<EnumOptions>(command);
var parseResult = command.Parse("--color ReD");
var options = OptionBinder.BindOptions<EnumOptions>(parseResult);
Assert.Equal(Color.Red, options.Color);
}
[Fact]
public void BindOptions_NullableEnum_NullWhenNotProvided()
{
var command = new Command("test");
OptionBinder.RegisterOptions<EnumOptions>(command);
var parseResult = command.Parse("--color Red");
var options = OptionBinder.BindOptions<EnumOptions>(parseResult);
Assert.Equal(Color.Red, options.Color);
Assert.Null(options.Background);
}
[Fact]
public void BindOptions_Guid_BindsValue()
{
var command = new Command("test");
OptionBinder.RegisterOptions<GuidOptions>(command);
var guid = Guid.NewGuid();
var parseResult = command.Parse($"--id {guid}");
var options = OptionBinder.BindOptions<GuidOptions>(parseResult);
Assert.Equal(guid, options.Id);
Assert.Null(options.CorrelationId);
}
[Fact]
public void BindOptions_DateTime_BindsValue()
{
var command = new Command("test");
OptionBinder.RegisterOptions<DateTimeOptions>(command);
var parseResult = command.Parse("--start-date 2024-01-15");
var options = OptionBinder.BindOptions<DateTimeOptions>(parseResult);
Assert.Equal(new DateTime(2024, 1, 15), options.StartDate);
}
[Fact]
public void BindOptions_Decimal_BindsValue()
{
var command = new Command("test");
OptionBinder.RegisterOptions<DecimalOptions>(command);
var parseResult = command.Parse("--price 19.99 --rate 3.14");
var options = OptionBinder.BindOptions<DecimalOptions>(parseResult);
Assert.Equal(19.99m, options.Price);
Assert.Equal(3.14, options.Rate);
}
#endregion
#region Nested Options Tests
[Fact]
public void RegisterOptions_NestedOptional_FlattenedWithPrefix()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
// --name, --optional-host, --optional-port, --required-host, --required-port
Assert.Equal(5, command.Options.Count);
Assert.Contains(command.Options, o => o.Name == "--name");
Assert.Contains(command.Options, o => o.Name == "--optional-host");
Assert.Contains(command.Options, o => o.Name == "--optional-port");
Assert.Contains(command.Options, o => o.Name == "--required-host");
Assert.Contains(command.Options, o => o.Name == "--required-port");
}
[Fact]
public void RegisterOptions_NestedOptional_AllChildrenAreOptional()
{
// NetworkSettings has all-nullable children, so whether parent is nullable or not,
// the leaf options remain optional
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
foreach (var option in command.Options)
{
Assert.False(option.Required, $"Option {option.Name} should be optional");
}
}
[Fact]
public void BindOptions_NestedOptional_NullWhenNoChildProvided()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
var parseResult = command.Parse("--name myapp");
var options = OptionBinder.BindOptions<NestedOptional>(parseResult);
Assert.Equal("myapp", options.Name);
Assert.Null(options.Optional);
}
[Fact]
public void BindOptions_NestedOptional_BindsOptionalChild()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
var parseResult = command.Parse("--optional-host localhost --optional-port 8080");
var options = OptionBinder.BindOptions<NestedOptional>(parseResult);
Assert.NotNull(options.Optional);
Assert.Equal("localhost", options.Optional!.Host);
Assert.Equal(8080, options.Optional.Port);
}
[Fact]
public void BindOptions_NestedOptional_BindsRequiredChild()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
var parseResult = command.Parse("--required-host 10.0.0.1 --required-port 443");
var options = OptionBinder.BindOptions<NestedOptional>(parseResult);
Assert.NotNull(options.Required);
Assert.Equal("10.0.0.1", options.Required.Host);
Assert.Equal(443, options.Required.Port);
}
[Fact]
public void BindOptions_NestedOptional_PartialChildValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedOptional>(command);
var parseResult = command.Parse("--optional-host localhost");
var options = OptionBinder.BindOptions<NestedOptional>(parseResult);
Assert.NotNull(options.Optional);
Assert.Equal("localhost", options.Optional!.Host);
Assert.Null(options.Optional.Port);
}
[Fact]
public void RegisterOptions_NestedRequired_DeepFlattenWithPrefix()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedRequired>(command);
// --name, --required-name (required!), --required-port (optional)
Assert.Equal(3, command.Options.Count);
Assert.Contains(command.Options, o => o.Name == "--name");
Assert.Contains(command.Options, o => o.Name == "--required-name");
Assert.Contains(command.Options, o => o.Name == "--required-port");
}
[Fact]
public void RegisterOptions_NestedRequired_ScalarChildIsRequired()
{
// RequiredNetworkSettings.Name is non-nullable string → required
var command = new Command("test");
OptionBinder.RegisterOptions<NestedRequired>(command);
var requiredName = command.Options.Single(o => o.Name == "--required-name");
Assert.True(requiredName.Required);
// Port is nullable → optional
var port = command.Options.Single(o => o.Name == "--required-port");
Assert.False(port.Required);
}
[Fact]
public void BindOptions_NestedRequired_BindsDeepValues()
{
var command = new Command("test");
OptionBinder.RegisterOptions<NestedRequired>(command);
var parseResult = command.Parse("--name myapp --required-name primary --required-port 5432");
var options = OptionBinder.BindOptions<NestedRequired>(parseResult);
Assert.Equal("myapp", options.Name);
Assert.NotNull(options.Required);
Assert.Equal("primary", options.Required.Name);
Assert.Equal(5432, options.Required.Port);
}
#endregion
#region Thread Safety Tests
[Fact]
public void GetHandler_Enum_ThreadSafe()
{
// Exercise concurrent access to enum handler caching
var exceptions = new List<Exception>();
Parallel.For(0, 100, _ =>
{
try
{
var command = new Command("test");
OptionBinder.RegisterOptions<EnumOptions>(command);
var parseResult = command.Parse("--color Green");
var options = OptionBinder.BindOptions<EnumOptions>(parseResult);
Assert.Equal(Color.Green, options.Color);
}
catch (Exception ex)
{
lock (exceptions)
{
exceptions.Add(ex);
}
}
});
Assert.Empty(exceptions);
}
#endregion
}
+59 -13
View File
@@ -122,30 +122,76 @@ function BuildServer($server) {
if ($SmokeTest) {
if ($runtime -eq $currentRid) {
Write-Host "Running smoke test for $exeName" -ForegroundColor Yellow
$stdoutFile = [System.IO.Path]::GetTempFileName()
$stderrFile = [System.IO.Path]::GetTempFileName()
try {
$toolListResponse = & $exePath tools list
if ($LASTEXITCODE -ne 0) {
# Call returned a non-zero exit code, log response without attempting to parse as JSON as the
# response may be an error message rather than the expected JSON tools list.
Write-Error $toolListResponse
$proc = Start-Process -FilePath $exePath -ArgumentList @('tools', 'list') `
-NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $stdoutFile `
-RedirectStandardError $stderrFile
$procExitCode = $proc.ExitCode
$stdout = Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction SilentlyContinue
$stderr = Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue
if ($null -eq $stdout) { $stdout = '' }
if ($null -eq $stderr) { $stderr = '' }
$smokeFailed = $false
$failureReason = ''
if ($procExitCode -ne 0) {
$smokeFailed = $true
$failureReason = "exit code $procExitCode (expected 0)"
}
else {
$toolListJson = ConvertFrom-Json ($toolListResponse | Join-String)
if ($toolListJson.status -ne 200) {
# Only log the response if there was an error. tools list is unbounded on size and can be
# difficult to read in the logs, so we want to avoid logging it on success.
# For example, at the time of writing this, Azure MCP's tools list is >20k lines long.
Write-Error $toolListResponse
$toolListJson = $null
try {
$toolListJson = ConvertFrom-Json $stdout -ErrorAction Stop
}
catch {
$smokeFailed = $true
$failureReason = "could not parse stdout as JSON: $($_.Exception.Message)"
}
if (-not $smokeFailed -and $toolListJson.status -ne 200) {
$smokeFailed = $true
$failureReason = "non-200 status in response (got $($toolListJson.status))"
}
}
if ($smokeFailed) {
# tools list is unbounded on size, but on failure the response is the error payload,
# which is what we want to log so the failure is actionable in CI.
LogError "Smoke test failed for '$exeName': $failureReason"
Write-Host "--- stdout ---" -ForegroundColor Yellow
if ([string]::IsNullOrEmpty($stdout)) { Write-Host '(empty)' } else { Write-Host $stdout }
Write-Host "--- stderr ---" -ForegroundColor Yellow
if ([string]::IsNullOrEmpty($stderr)) { Write-Host '(empty)' } else { Write-Host $stderr }
Write-Host "--- end smoke test output ---" -ForegroundColor Yellow
$script:exitCode = 1
return
}
Write-Host "Smoke test passed for '$exeName'. 'tools list' command executed successfully and returned 200 status code." -ForegroundColor Green
}
catch {
LogError "Smoke test failed for '$exeName'. Executable did not run successfully."
LogError "Smoke test failed for '$exeName' while invoking the executable: $($_.Exception.Message)"
$stdout = Get-Content -LiteralPath $stdoutFile -Raw -ErrorAction SilentlyContinue
$stderr = Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue
Write-Host "--- stdout ---" -ForegroundColor Yellow
if ([string]::IsNullOrEmpty($stdout)) { Write-Host '(empty)' } else { Write-Host $stdout }
Write-Host "--- stderr ---" -ForegroundColor Yellow
if ([string]::IsNullOrEmpty($stderr)) { Write-Host '(empty)' } else { Write-Host $stderr }
Write-Host "--- end smoke test output ---" -ForegroundColor Yellow
$script:exitCode = 1
return
}
finally {
Remove-Item -LiteralPath $stdoutFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
}
}
else {
else {
Write-Host "Skipping smoke test for cross platorm build (current platform: $currentRid, build platform: $runtime)" -ForegroundColor Yellow
}
}