diff --git a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj index 949b8e3918..21adcaea67 100644 --- a/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj +++ b/src/UniGetUI.PackageEngine.AgentBroker/UniGetUI.PackageEngine.AgentBroker.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs index 233d17a43f..18d06ba5d1 100644 --- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs @@ -125,6 +125,16 @@ protected CancellationToken CancellationToken } } + /// + /// Test hook: installs the cancellation source that MainThread() would normally create, + /// so tests invoking PerformOperation() directly can exercise Cancel(). + /// + internal void SetRunCancellationSourceForTests(CancellationTokenSource source) + { + lock (CancellationLock) + RunCancellationSource = source; + } + private bool TrySetActiveInnerOperation(AbstractOperation operation) { bool cancellationRequested; diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index e8cf5e9f47..ef1e20429d 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -1,4 +1,3 @@ -using System.Text; using UniGetUI.Core.Classes; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; @@ -17,8 +16,12 @@ using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind; using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException; using BrokerClientOptions = Devolutions.Now.Policy.Client.BrokerClientOptions; +using BrokerDecision = Devolutions.Now.Policy.Api.Decision; using BrokerElevation = Devolutions.Now.Policy.Api.Elevation; using BrokerOperationStatus = Devolutions.Now.Policy.Api.OperationStatus; +using BrokerStatusResponse = Devolutions.Now.Policy.Api.StatusResponse; +using OperationCancelQuery = Devolutions.Now.Policy.Client.OperationCancelQuery; +using OperationStatusQuery = Devolutions.Now.Policy.Client.OperationStatusQuery; #if WINDOWS using UniGetUI.PackageEngine.Managers.WingetManager; #endif @@ -40,6 +43,28 @@ public abstract class PackageOperation : AbstractProcessOperation /// internal static Func? BrokerTransportFactory; + /// + /// Interval between broker operation status polls. Internal so tests can shorten it. + /// + internal static int BrokerStatusPollIntervalMs = 500; + + /// + /// Maximum time to wait for the broker to accept a cancel request. + /// + internal static TimeSpan BrokerCancelRequestTimeout = TimeSpan.FromSeconds(5); + + /// + /// Maximum time to wait for a canceled broker operation to reach a terminal status. + /// + internal static TimeSpan BrokerCancelConfirmTimeout = TimeSpan.FromSeconds(30); + + /// + /// Upper bound for a brokered operation to reach a terminal status before the + /// operation is reported as failed. Protects against a broker that keeps + /// reporting a non-terminal status indefinitely. + /// + internal static TimeSpan BrokerOperationTimeout = TimeSpan.FromHours(1); + protected List DesktopShortcutsBeforeStart = []; public readonly IPackage Package; @@ -240,42 +265,62 @@ private async Task PerformBrokerOperation() try { - // Send to broker and poll until completion, honoring operation cancellation. - var status = await client.ExecuteAndWait(request, CancellationToken); + // Submit the operation explicitly (instead of ExecuteAndWait) so the + // operation id is available for broker-side cancellation. + var execution = await client.Execute(request, CancellationToken); - // Log status details. - Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information); - if (!string.IsNullOrWhiteSpace(status.Message)) + if (execution.Decision.Decision != BrokerDecision.Allow) { - Line($" Message: {status.Message}", LineType.Information); + string denialReason = execution.Decision.Reason ?? CoreTools.Translate("No reason provided"); + Line($"Operation denied by policy: {denialReason}", LineType.Error); + Metadata.FailureTitle = CoreTools.Translate("Operation denied by policy"); + Metadata.FailureMessage = denialReason; + return OperationVeredict.Failure; } - var output = DisplayBrokerOutput(status.Stdout); - if (status.Status == BrokerOperationStatus.Completed) + if (execution.Operation is null) { - var veredict = await GetProcessVeredict(status.ExitCode ?? -1, output); - if (veredict is OperationVeredict.Success) - { - Line("Operation completed successfully via agent broker.", LineType.Information); - } - else if (!string.IsNullOrWhiteSpace(status.Message)) - { - Metadata.FailureMessage = status.Message; - } + Line("Broker allowed the operation but did not return an operation submission.", LineType.Error); + Metadata.FailureTitle = CoreTools.Translate("Operation failed via broker"); + Metadata.FailureMessage = CoreTools.Translate( + "The broker accepted the request but did not report an operation to track."); + return OperationVeredict.Failure; + } - return veredict; + string operationId = execution.Operation.OperationId; + Line($"Broker accepted operation: {operationId}", LineType.VerboseDetails); + + // NOTE: execution.Operation.EventChannel (live output streaming) is intentionally + // not consumed yet; brokered operations show no captured output until then. + + BrokerStatusResponse status; + using var operationTimeout = new CancellationTokenSource(BrokerOperationTimeout); + using var polling = CancellationTokenSource.CreateLinkedTokenSource( + CancellationToken, operationTimeout.Token); + try + { + status = await WaitForBrokerTerminalStatus(client, operationId, polling.Token); + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return await CancelBrokerOperation(client, operationId); + } + catch (OperationCanceledException) when (operationTimeout.IsCancellationRequested) + { + string timeoutMessage = CoreTools.Translate( + "The operation did not finish within the allotted time. It may still be running on the agent."); + Line($"Broker operation timed out after {BrokerOperationTimeout}.", LineType.Error); + Logger.Error($"[AgentBroker] Operation {operationId} did not reach a terminal status within {BrokerOperationTimeout}"); + Metadata.FailureTitle = CoreTools.Translate("Operation failed via broker"); + Metadata.FailureMessage = timeoutMessage; + return OperationVeredict.Failure; } - // Operation failed — surface a user-visible error. - string reason = status.Message ?? $"Exit code: {status.ExitCode}"; - Line($"Operation failed via broker: {reason}", LineType.Error); - Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker"); - Metadata.FailureMessage = reason; - return OperationVeredict.Failure; + return await InterpretBrokerTerminalStatus(status); } catch (OperationCanceledException) { - Line("Broker operation was canceled.", LineType.Error); + Line("Broker operation was canceled.", LineType.Information); return OperationVeredict.Canceled; } catch (BrokerClientException ex) when (ex.Kind is BrokerClientErrorKind.BrokerUnavailable) @@ -296,55 +341,138 @@ private async Task PerformBrokerOperation() } /// - /// Fails the operation because the agent broker is unreachable: brokered operations - /// must not fall back to local execution, since policy evaluation and kill/pre/post - /// actions are owned by the broker. Sets the failure metadata and raises - /// so the UI can notify the user. + /// Polls the broker until the operation reaches a terminal status + /// (Completed, Failed or Canceled). /// - private OperationVeredict HandleBrokerUnavailable() + private static async Task WaitForBrokerTerminalStatus( + BrokerClient client, + string operationId, + CancellationToken cancellationToken) { - Line("Agent broker is not available. The operation cannot continue.", LineType.Error); - Logger.Error("[AgentBroker] Broker not available, aborting operation"); - string message = CoreTools.Translate( - "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running."); - Metadata.FailureTitle = CoreTools.Translate("Agent broker unavailable"); - Metadata.FailureMessage = message; - BrokerUnavailable?.Invoke(this, message); - return OperationVeredict.Failure; + while (true) + { + await Task.Delay(BrokerStatusPollIntervalMs, cancellationToken); + + var status = await client.QueryStatus( + new OperationStatusQuery { OperationId = operationId }, + cancellationToken); + + if (status.Status is BrokerOperationStatus.Completed + or BrokerOperationStatus.Failed + or BrokerOperationStatus.Canceled) + { + return status; + } + } } - private List DisplayBrokerOutput(string? encodedStdout) + /// + /// Requests broker-side cancellation of a running operation, then waits (bounded) + /// for the operation to reach a terminal status. The remote process may win the + /// race and complete or fail before the cancel takes effect; in that case the + /// terminal status is honored instead of reporting a cancellation. + /// + private async Task CancelBrokerOperation(BrokerClient client, string operationId) { - List output = []; - if (string.IsNullOrWhiteSpace(encodedStdout)) + Line("Cancellation requested; asking broker to cancel the remote operation...", LineType.Information); + + try { - return output; + using var cancelTimeout = new CancellationTokenSource(BrokerCancelRequestTimeout); + var cancelResponse = await client.Cancel( + new OperationCancelQuery { OperationId = operationId }, + cancelTimeout.Token); + Line($"Broker acknowledged cancel request: {cancelResponse.Status}", LineType.VerboseDetails); + } + catch (Exception ex) + { + // Best-effort: the cancel request is idempotent, and the operation may already + // have reached a terminal state. Still wait below for the terminal status. + Logger.Warn($"[AgentBroker] Cancel request for operation {operationId} failed: {ex}"); + Line("Broker cancel request failed; checking final operation status...", LineType.Information); } - string decoded; try { - decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encodedStdout)); + using var confirmTimeout = new CancellationTokenSource(BrokerCancelConfirmTimeout); + var status = await WaitForBrokerTerminalStatus(client, operationId, confirmTimeout.Token); + + if (status.Status is not BrokerOperationStatus.Canceled) + { + // The remote process finished before the cancel took effect. + Line($"Broker operation finished before cancellation took effect: {status.Status}", LineType.Information); + return await InterpretBrokerTerminalStatus(status); + } + } + catch (Exception ex) + { + // The user asked for cancellation; do not surface polling failures as errors. + Logger.Warn($"[AgentBroker] Could not confirm terminal status of canceled operation {operationId}: {ex}"); } - catch (FormatException ex) + + Line("Broker operation was canceled.", LineType.Information); + return OperationVeredict.Canceled; + } + + /// + /// Maps a terminal broker status response to an operation veredict, setting + /// failure metadata where appropriate. + /// + private async Task InterpretBrokerTerminalStatus(BrokerStatusResponse status) + { + Line($"Broker status: {status.Status}, exitCode={status.ExitCode}", LineType.Information); + if (!string.IsNullOrWhiteSpace(status.Message)) + { + Line($" Message: {status.Message}", LineType.Information); + } + + if (status.Status is BrokerOperationStatus.Canceled) { - Logger.Error($"[AgentBroker] Broker returned invalid base64 stdout: {ex}"); - Line("Broker returned captured output in an invalid format.", LineType.Error); - return output; + Line("Broker operation was canceled.", LineType.Information); + return OperationVeredict.Canceled; } - foreach (var line in decoded.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) + if (status.Status is BrokerOperationStatus.Completed) { - if (line.Length == 0) + // Captured output is not available anymore over the status endpoint; live + // output will be restored through the per-operation event channel. + var veredict = await GetProcessVeredict(status.ExitCode ?? -1, []); + if (veredict is OperationVeredict.Success) + { + Line("Operation completed successfully via agent broker.", LineType.Information); + } + else if (!string.IsNullOrWhiteSpace(status.Message)) { - continue; + Metadata.FailureMessage = status.Message; } - output.Add(line); - Line(line, LineType.Information); + return veredict; } - return output; + // Operation failed — surface a user-visible error. + string reason = status.Message ?? $"Exit code: {status.ExitCode}"; + Line($"Operation failed via broker: {reason}", LineType.Error); + Metadata.FailureTitle = CoreTools.Translate("Operation denied or failed via broker"); + Metadata.FailureMessage = reason; + return OperationVeredict.Failure; + } + + /// + /// Fails the operation because the agent broker is unreachable: brokered operations + /// must not fall back to local execution, since policy evaluation and kill/pre/post + /// actions are owned by the broker. Sets the failure metadata and raises + /// so the UI can notify the user. + /// + private OperationVeredict HandleBrokerUnavailable() + { + Line("Agent broker is not available. The operation cannot continue.", LineType.Error); + Logger.Error("[AgentBroker] Broker not available, aborting operation"); + string message = CoreTools.Translate( + "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running."); + Metadata.FailureTitle = CoreTools.Translate("Agent broker unavailable"); + Metadata.FailureMessage = message; + BrokerUnavailable?.Invoke(this, message); + return OperationVeredict.Failure; } private static BrokerClient CreateBrokerClient(bool requestedElevation) => diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs index 01f9de8af0..2029198600 100644 --- a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -14,8 +14,24 @@ using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; using UniGetUI.PackageOperations; +using BrokerApiCancelResponse = Devolutions.Now.Policy.Api.CancelResponse; +using BrokerApiCapabilitiesResponse = Devolutions.Now.Policy.Api.CapabilitiesResponse; +using BrokerApiConstants = Devolutions.Now.Policy.Api.BrokerApi; +using BrokerApiDecision = Devolutions.Now.Policy.Api.Decision; +using BrokerApiDecisionInfo = Devolutions.Now.Policy.Api.DecisionInfo; +using BrokerApiExecutionResponse = Devolutions.Now.Policy.Api.ExecutionResponse; +using BrokerApiHealthResponse = Devolutions.Now.Policy.Api.HealthResponse; +using BrokerApiHealthStatus = Devolutions.Now.Policy.Api.HealthStatus; +using BrokerApiManagerCapability = Devolutions.Now.Policy.Api.ManagerCapability; +using BrokerApiManagerName = Devolutions.Now.Policy.Api.ManagerName; +using BrokerApiOperation = Devolutions.Now.Policy.Api.Operation; +using BrokerApiOperationStatus = Devolutions.Now.Policy.Api.OperationStatus; +using BrokerApiOperationSubmission = Devolutions.Now.Policy.Api.OperationSubmission; +using BrokerApiServerContext = Devolutions.Now.Policy.Api.ServerContext; +using BrokerApiStatusResponse = Devolutions.Now.Policy.Api.StatusResponse; using BrokerClientErrorKind = Devolutions.Now.Policy.Client.BrokerClientErrorKind; using BrokerClientException = Devolutions.Now.Policy.Client.BrokerClientException; +using BrokerJson = Devolutions.Now.Policy.Api.BrokerJson; using BrokerTransportKind = Devolutions.Now.Policy.Api.Transport; using BrokerTransportRequest = Devolutions.Now.Policy.Client.BrokerTransportRequest; using BrokerTransportResponse = Devolutions.Now.Policy.Client.BrokerTransportResponse; @@ -595,6 +611,137 @@ private static async Task AssertBrokerUnavailableFailure(FakeBrokerTransport tra } } + /// + /// Runs an install operation against a scripted broker transport with the UseAgentBroker + /// setting enabled, fast status polling, and short cancel timeouts. The caller scripts the + /// transport behavior and can trigger operation cancellation from transport callbacks. + /// + private static async Task RunBrokeredOperation( + ScriptedBrokerTransport transport, + Action? configureCancellation = null, + TimeSpan? operationTimeout = null) + { + bool originalSetting = Settings.Get(Settings.K.UseAgentBroker); + int originalPollInterval = PackageOperation.BrokerStatusPollIntervalMs; + TimeSpan originalCancelRequestTimeout = PackageOperation.BrokerCancelRequestTimeout; + TimeSpan originalCancelConfirmTimeout = PackageOperation.BrokerCancelConfirmTimeout; + TimeSpan originalOperationTimeout = PackageOperation.BrokerOperationTimeout; + var manager = new PackageManagerBuilder() + .WithName("Chocolatey") + .ConfigureManager(m => + { + m.ExecutablePath = "C:\\test-tools\\choco.exe"; + m.ExecutableArguments = "--test"; + }) + .Build(); + var package = new PackageBuilder().WithManager(manager).Build(); + PackageOperation.BrokerTransportFactory = () => transport; + PackageOperation.BrokerStatusPollIntervalMs = 5; + PackageOperation.BrokerCancelRequestTimeout = TimeSpan.FromSeconds(2); + PackageOperation.BrokerCancelConfirmTimeout = TimeSpan.FromSeconds(2); + if (operationTimeout is not null) + PackageOperation.BrokerOperationTimeout = operationTimeout.Value; + Settings.Set(Settings.K.UseAgentBroker, true); + try + { + using var operation = new BrokerProbingInstallPackageOperation(package, new InstallOptions()); + if (configureCancellation is not null) + { + // Attach a cancellation source the same way MainThread() would, so the + // operation's CancellationToken plumbing is exercised end-to-end. + var cancellationSource = new CancellationTokenSource(); + operation.SetRunCancellationSourceForTests(cancellationSource); + configureCancellation(cancellationSource); + } + + return await operation.InvokePerformOperationForTests().WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + Settings.Set(Settings.K.UseAgentBroker, originalSetting); + PackageOperation.BrokerTransportFactory = null; + PackageOperation.BrokerStatusPollIntervalMs = originalPollInterval; + PackageOperation.BrokerCancelRequestTimeout = originalCancelRequestTimeout; + PackageOperation.BrokerCancelConfirmTimeout = originalCancelConfirmTimeout; + PackageOperation.BrokerOperationTimeout = originalOperationTimeout; + } + } + + [Fact] + public async Task CancelingBrokeredOperationRequestsRemoteCancelAndYieldsCanceledVeredict() + { + var transport = new ScriptedBrokerTransport(); + transport.StatusScript.Enqueue(BrokerApiOperationStatus.Running); + transport.StatusScript.Enqueue(BrokerApiOperationStatus.Running); + transport.StatusAfterCancel = BrokerApiOperationStatus.Canceled; + + var veredict = await RunBrokeredOperation( + transport, + cancellation => transport.OnStatusQueried = () => + { + if (transport.StatusQueryCount >= 2) + cancellation.Cancel(); + }); + + Assert.Equal(OperationVeredict.Canceled, veredict); + Assert.Equal(1, transport.CancelRequestCount); + Assert.Contains("/v1/package-operations/cancel", transport.RequestedPaths); + } + + [Fact] + public async Task CanceledBrokeredOperationHonorsCompletedTerminalStatusWhenProcessWinsTheRace() + { + var transport = new ScriptedBrokerTransport(); + transport.StatusScript.Enqueue(BrokerApiOperationStatus.Running); + // The remote process finishes before the broker-side cancel takes effect. + transport.StatusAfterCancel = BrokerApiOperationStatus.Completed; + transport.CompletedExitCode = 0; + + var veredict = await RunBrokeredOperation( + transport, + cancellation => transport.OnStatusQueried = () => cancellation.Cancel()); + + Assert.Equal(OperationVeredict.Success, veredict); + Assert.Equal(1, transport.CancelRequestCount); + } + + [Fact] + public async Task FailedBrokerCancelRequestStillYieldsCanceledVeredict() + { + var transport = new ScriptedBrokerTransport + { + FailCancelRequests = true, + }; + transport.StatusScript.Enqueue(BrokerApiOperationStatus.Running); + // The broker keeps reporting a non-terminal status, so the bounded + // confirmation wait times out and the cancellation is honored anyway. + transport.StatusAfterCancel = BrokerApiOperationStatus.Canceling; + + var veredict = await RunBrokeredOperation( + transport, + cancellation => transport.OnStatusQueried = () => cancellation.Cancel()); + + Assert.Equal(OperationVeredict.Canceled, veredict); + Assert.Equal(1, transport.CancelRequestCount); + } + + [Fact] + public async Task BrokeredOperationThatNeverReachesTerminalStatusFailsAfterTimeout() + { + var transport = new ScriptedBrokerTransport + { + // The broker keeps reporting Running forever. + StatusAfterCancel = BrokerApiOperationStatus.Running, + }; + + var veredict = await RunBrokeredOperation( + transport, + operationTimeout: TimeSpan.FromMilliseconds(200)); + + Assert.Equal(OperationVeredict.Failure, veredict); + Assert.Equal(0, transport.CancelRequestCount); + } + private static IReadOnlyList GetInnerOperations( AbstractOperation operation, string fieldName @@ -685,6 +832,140 @@ public Task Send( public void Dispose() { } } + /// + /// A scriptable broker transport implementing the full happy-path endpoint surface + /// (health, capabilities, execute, get-status, cancel) with responses built from the + /// real Api types via . Status responses are drained from + /// ; once a cancel request has been received (or the script + /// is empty), is reported instead. + /// + private sealed class ScriptedBrokerTransport : IBrokerTransport + { + private const string OperationId = "test-operation-1"; + + public List RequestedPaths { get; } = []; + public Queue StatusScript { get; } = new(); + public BrokerApiOperationStatus StatusAfterCancel { get; set; } = BrokerApiOperationStatus.Canceled; + public int CompletedExitCode { get; set; } + public bool FailCancelRequests { get; set; } + public int StatusQueryCount { get; private set; } + public int CancelRequestCount { get; private set; } + public Action? OnStatusQueried { get; set; } + + private bool cancelReceived; + + public BrokerTransportKind Kind => BrokerTransportKind.HttpNamedPipe; + + public Task Send( + BrokerTransportRequest request, + CancellationToken cancellationToken = default + ) + { + RequestedPaths.Add(request.Path); + return request.Path switch + { + "/v1/health" => Json(BrokerJson.Serialize(BuildHealthResponse())), + "/v1/capabilities" => Json(BrokerJson.Serialize(BuildCapabilities())), + "/v1/package-operations/execute" => Json(BrokerJson.Serialize(BuildExecutionResponse())), + "/v1/package-operations/get-status" => HandleStatusQuery(), + "/v1/package-operations/cancel" => HandleCancelRequest(), + _ => throw new BrokerClientException( + BrokerClientErrorKind.InvalidRequest, + $"Unexpected request path: {request.Path}", + request.Path), + }; + } + + public void Dispose() { } + + private static Task Json(string body) => + Task.FromResult(new BrokerTransportResponse { StatusCode = 200, Body = body }); + + private Task HandleStatusQuery() + { + StatusQueryCount++; + OnStatusQueried?.Invoke(); + BrokerApiOperationStatus status = + cancelReceived || StatusScript.Count == 0 + ? StatusAfterCancel + : StatusScript.Dequeue(); + + return Json(BrokerJson.Serialize(new BrokerApiStatusResponse + { + ResponseKind = BrokerApiConstants.StatusResponseKind, + ResponseVersion = BrokerApiConstants.Version, + OperationId = OperationId, + Status = status, + ExitCode = status is BrokerApiOperationStatus.Completed ? CompletedExitCode : null, + })); + } + + private Task HandleCancelRequest() + { + CancelRequestCount++; + if (FailCancelRequests) + { + throw new BrokerClientException( + BrokerClientErrorKind.BrokerError, + "Simulated cancel failure", + "/v1/package-operations/cancel"); + } + + cancelReceived = true; + return Json(BrokerJson.Serialize(new BrokerApiCancelResponse + { + ResponseKind = BrokerApiConstants.CancelResponseKind, + ResponseVersion = BrokerApiConstants.Version, + OperationId = OperationId, + Status = BrokerApiOperationStatus.Canceling, + })); + } + + private static BrokerApiHealthResponse BuildHealthResponse() => new() + { + ResponseKind = BrokerApiConstants.HealthResponseKind, + ResponseVersion = BrokerApiConstants.Version, + Server = new BrokerApiServerContext + { + ServerVersion = "0.0.0-tests", + Transport = BrokerTransportKind.HttpNamedPipe, + }, + Status = BrokerApiHealthStatus.Ready, + }; + + private static BrokerApiCapabilitiesResponse BuildCapabilities() => new() + { + ResponseKind = BrokerApiConstants.CapabilitiesResponseKind, + ResponseVersion = BrokerApiConstants.Version, + MaxRequestBodyBytes = 1_000_000, + Transports = [BrokerTransportKind.HttpNamedPipe], + Managers = + [ + new BrokerApiManagerCapability + { + Manager = BrokerApiManagerName.Chocolatey, + Operations = [BrokerApiOperation.Install, BrokerApiOperation.Update, BrokerApiOperation.Uninstall], + SupportsCustomParameters = true, + SupportsCustomInstallLocation = true, + SupportsCaptureOutput = true, + }, + ], + }; + + private static BrokerApiExecutionResponse BuildExecutionResponse() => new() + { + ResponseKind = BrokerApiConstants.ExecutionResponseKind, + ResponseVersion = BrokerApiConstants.Version, + Decision = new BrokerApiDecisionInfo { Decision = BrokerApiDecision.Allow }, + Operation = new BrokerApiOperationSubmission + { + OperationId = OperationId, + Status = BrokerApiOperationStatus.Starting, + SubmittedAt = DateTimeOffset.UtcNow, + }, + }; + } + private class InspectableInstallPackageOperation : InstallPackageOperation { public InspectableInstallPackageOperation(