|
2 | 2 | // Licensed under the MIT License. |
3 | 3 |
|
4 | 4 | using System.CommandLine; |
| 5 | +using System.CommandLine.Builder; |
| 6 | +using System.CommandLine.Invocation; |
| 7 | +using System.CommandLine.Parsing; |
5 | 8 | using FluentAssertions; |
6 | 9 | using Microsoft.Agents.A365.DevTools.Cli.Commands; |
| 10 | +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; |
7 | 11 | using Microsoft.Agents.A365.DevTools.Cli.Services; |
8 | 12 | using Microsoft.Agents.A365.DevTools.Cli.Services.Evaluate; |
9 | 13 | using Microsoft.Extensions.Logging; |
| 14 | +using Microsoft.Extensions.Logging.Abstractions; |
10 | 15 | using NSubstitute; |
11 | 16 | using Xunit; |
12 | 17 |
|
@@ -101,4 +106,96 @@ await pipeline.Received(1).RunAsync( |
101 | 106 | Environment.SetEnvironmentVariable(AuthTokenEnvVar, original); |
102 | 107 | } |
103 | 108 | } |
| 109 | + |
| 110 | + // The bare Command.InvokeAsync(string) shortcut used above wraps every thrown exception |
| 111 | + // as exit code 1 via System.CommandLine's default handler, so it cannot observe an |
| 112 | + // EvaluationException's ExitCode (3). The tests below build a parser that mirrors the |
| 113 | + // exception-to-exit-code mapping in Program.cs (Agent365Exception.ExitCode) plus its |
| 114 | + // parse-error short-circuit, and drive a real EvaluationPipelineService (sub-dependencies |
| 115 | + // mocked) so ParseEvalEngine and the output-dir guard actually run. This pins the |
| 116 | + // invocation-layer exit codes against a refactor of the SetHandler wiring that would |
| 117 | + // otherwise break those branches with all tests still green. |
| 118 | + private static Parser BuildFaithfulParser(Command evaluate) => |
| 119 | + new CommandLineBuilder(evaluate) |
| 120 | + .UseExceptionHandler((exception, context) => |
| 121 | + context.ExitCode = exception is Agent365Exception agentEx ? agentEx.ExitCode : 1) |
| 122 | + .AddMiddleware(async (context, next) => |
| 123 | + { |
| 124 | + // Mirror Program.cs: a parse error (e.g. a missing required option) exits |
| 125 | + // non-zero before the command handler runs. |
| 126 | + if (context.ParseResult.Errors.Count > 0) |
| 127 | + { |
| 128 | + context.ExitCode = 1; |
| 129 | + return; |
| 130 | + } |
| 131 | + |
| 132 | + await next(context); |
| 133 | + }, MiddlewareOrder.ErrorReporting) |
| 134 | + .Build(); |
| 135 | + |
| 136 | + // Real pipeline so ParseEvalEngine and the output-dir guard execute; the sub-dependencies |
| 137 | + // are mocked because the failure paths under test throw or return before reaching them. |
| 138 | + private static IEvaluationPipelineService RealPipeline() => |
| 139 | + new EvaluationPipelineService( |
| 140 | + NullLogger<EvaluationPipelineService>.Instance, |
| 141 | + Substitute.For<ISchemaDiscoveryService>(), |
| 142 | + Substitute.For<IChecklistGenerator>(), |
| 143 | + Substitute.For<IChecklistEvaluator>(), |
| 144 | + Substitute.For<IEvaluationAnalyzer>(), |
| 145 | + Substitute.For<IReportGenerator>(), |
| 146 | + Substitute.For<IAgent365ToolingService>()); |
| 147 | + |
| 148 | + [Fact] |
| 149 | + public async Task InvokeAsync_InvalidEvalEngine_ReturnsEvaluationExceptionExitCode3() |
| 150 | + { |
| 151 | + var evaluate = GetEvaluateSubcommand(Substitute.For<ILogger>(), RealPipeline()); |
| 152 | + var parser = BuildFaithfulParser(evaluate); |
| 153 | + |
| 154 | + var exitCode = await parser.InvokeAsync( |
| 155 | + new[] { "--server-url", "http://localhost/mcp", "--eval-engine", "bogus" }); |
| 156 | + |
| 157 | + exitCode.Should().Be(3, because: "an unknown --eval-engine raises an EvaluationException, whose ExitCode (3) the real exception handler must propagate to the process"); |
| 158 | + } |
| 159 | + |
| 160 | + [Fact] |
| 161 | + public async Task InvokeAsync_MissingServerUrl_ReturnsExitCode1() |
| 162 | + { |
| 163 | + var evaluate = GetEvaluateSubcommand(Substitute.For<ILogger>(), PipelineReturning(0)); |
| 164 | + var parser = BuildFaithfulParser(evaluate); |
| 165 | + |
| 166 | + var exitCode = await parser.InvokeAsync(new[] { "--eval-engine", "none" }); |
| 167 | + |
| 168 | + exitCode.Should().Be(1, because: "--server-url is a required option; the missing-required-option parse error must exit non-zero without invoking the handler"); |
| 169 | + } |
| 170 | + |
| 171 | + [Fact] |
| 172 | + public async Task InvokeAsync_WhitespaceOutputDir_ReturnsExitCode1() |
| 173 | + { |
| 174 | + var evaluate = GetEvaluateSubcommand(Substitute.For<ILogger>(), RealPipeline()); |
| 175 | + var parser = BuildFaithfulParser(evaluate); |
| 176 | + |
| 177 | + var exitCode = await parser.InvokeAsync( |
| 178 | + new[] { "--server-url", "http://localhost/mcp", "--output-dir", " ", "--eval-engine", "none" }); |
| 179 | + |
| 180 | + exitCode.Should().Be(1, because: "an empty or whitespace --output-dir must fail fast with exit code 1 through the real handler wiring, not a deep exception later in the run"); |
| 181 | + } |
| 182 | + |
| 183 | + [Fact] |
| 184 | + public async Task InvokeAsync_ValidArguments_ForwardsEachOptionToRunAsync() |
| 185 | + { |
| 186 | + var pipeline = PipelineReturning(0); |
| 187 | + var evaluate = GetEvaluateSubcommand(Substitute.For<ILogger>(), pipeline); |
| 188 | + var parser = BuildFaithfulParser(evaluate); |
| 189 | + |
| 190 | + var exitCode = await parser.InvokeAsync( |
| 191 | + new[] { "--server-url", "http://localhost:5000/mcp", "--output-dir", "out", "--eval-engine", "claude-code" }); |
| 192 | + |
| 193 | + exitCode.Should().Be(0, because: "RunAsync returned 0, which the handler must propagate as the process exit code"); |
| 194 | + await pipeline.Received(1).RunAsync( |
| 195 | + "http://localhost:5000/mcp", |
| 196 | + "out", |
| 197 | + "claude-code", |
| 198 | + Arg.Any<string?>(), |
| 199 | + Arg.Any<CancellationToken>()); |
| 200 | + } |
104 | 201 | } |
0 commit comments