mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
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:
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<string>.
|
||||
/// </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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user