// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Text;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
namespace Aspire.Cli.Tests.Interaction;
public class ExtensionInteractionServiceTests(ITestOutputHelper outputHelper)
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task PromptForStringAsync_Secret_UsesMaskedPrompt(bool hasSecretPromptsCapability, bool required)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var output = new StringBuilder();
var console = new TestAnsiConsoleWithInput(
AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Interactive = InteractionSupport.Yes,
Out = new AnsiConsoleOutput(new StringWriter(output))
}),
new StringReader("y\n"));
var consoleInteractionService = new ConsoleInteractionService(
new ConsoleEnvironment(console, console, TextReader.Null),
workspace.CreateExecutionContext(),
TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(),
NullLoggerFactory.Instance,
new ConsoleLogBufferContext());
var secretPromptCalls = 0;
var regularPromptCalls = 0;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) =>
{
Assert.Equal(KnownCapabilities.SecretPrompts, capability);
return Task.FromResult(hasSecretPromptsCapability);
},
PromptForSecretStringAsyncCallback = (_, _, promptRequired) =>
{
secretPromptCalls++;
Assert.Equal(required, promptRequired);
return Task.FromResult("y");
},
PromptForStringAsyncCallback = (_, _, _, _) =>
{
regularPromptCalls++;
return Task.FromResult("unmasked");
}
};
using var interactionService = new ExtensionInteractionService(
consoleInteractionService, backchannel, extensionPromptEnabled: true,
NullLogger<ExtensionInteractionService>.Instance);
var result = await interactionService.PromptForStringAsync("Secret:", isSecret: true, required: required).DefaultTimeout();
Assert.Equal("y", result);
Assert.Equal(0, regularPromptCalls);
Assert.Equal(hasSecretPromptsCapability ? 1 : 0, secretPromptCalls);
Assert.Equal(hasSecretPromptsCapability ? "" : $"Secret: *{Environment.NewLine}", output.ToString());
}
[Fact]
public async Task DisplaySourceTrustWarning_NotifiesExtensionOnlyOnce()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var messages = new List<string>();
var backchannel = new TestExtensionBackchannel
{
DisplayMessageAsyncCallback = (_, message) =>
{
messages.Add(message);
return Task.CompletedTask;
}
};
using var interaction = CreateExtensionInteractionService(workspace, backchannel);
var executionContext = workspace.CreateExecutionContext();
interaction.DisplaySourceTrustWarning(executionContext);
interaction.DisplaySourceTrustWarning(executionContext);
await interaction.FlushAsync();
Assert.Equal([SharedCommandStrings.SourceTrustWarning], messages);
}
[Fact]
public async Task DisplayMessage_DoesNotRenderTerminalHyperlinksToDebugConsoleCapturedOutput()
{
var output = new StringBuilder();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor,
Out = new AnsiConsoleOutput(new StringWriter(output)),
Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
});
console.Profile.Capabilities.Links = true;
console.Profile.Width = int.MaxValue;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "cli [extension].log");
var executionContext = workspace.CreateExecutionContext(logFilePath: logFilePath);
var consoleInteractionService = new ConsoleInteractionService(
new ConsoleEnvironment(console, console, TextReader.Null),
executionContext,
TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(),
NullLoggerFactory.Instance,
new ConsoleLogBufferContext());
var extensionInteractionService = new ExtensionInteractionService(
consoleInteractionService,
new TestExtensionBackchannel(),
extensionPromptEnabled: false,
logger: NullLogger<ExtensionInteractionService>.Instance);
var fileLinkMarkup = MarkupHelpers.SafeFileLink(extensionInteractionService, logFilePath);
extensionInteractionService.DisplayMessage(
KnownEmojis.PageFacingUp,
string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, fileLinkMarkup),
allowMarkup: true,
consoleOverride: ConsoleOutput.Error);
await extensionInteractionService.FlushAsync();
var outputString = output.ToString();
Assert.Contains(logFilePath, outputString);
Assert.DoesNotContain("\u001b]8;", outputString);
Assert.DoesNotContain("file://", outputString);
}
[Fact]
public async Task DisplayError_WithActions_SendsStructuredActions()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.cli.log");
IReadOnlyList<InteractionMessageAction>? capturedActions = null;
var backchannel = new TestExtensionBackchannel
{
DisplayErrorWithActionsAsyncCallback = (_, actions) =>
{
capturedActions = actions;
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
interactionService.DisplayError(
"The project could not be built.",
[
InteractionMessageAction.OpenFile("Open CLI Log", logFilePath),
InteractionMessageAction.ExecuteCommand("Retry", "aspire-vscode.retry")
]);
await interactionService.FlushAsync();
Assert.Collection(
Assert.IsAssignableFrom<IReadOnlyList<InteractionMessageAction>>(capturedActions),
action =>
{
Assert.Equal("Open CLI Log", action.DisplayName);
Assert.Equal(logFilePath, action.FilePath);
Assert.Null(action.Command);
},
action =>
{
Assert.Equal("Retry", action.DisplayName);
Assert.Equal("aspire-vscode.retry", action.Command);
Assert.Null(action.FilePath);
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task TryDisplayCommandFailureAsync_WithError_CombinesDiagnosticLogActions(bool allowMarkup)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var cliLogFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.cli.log");
var appHostLogFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cli.log");
string? capturedError = null;
const string expectedError = "The project could not be built.\nSee https://docs.example/help?team=[platform].";
var errorMessage = allowMarkup
? "[bold]The project could not be built.[/]\nSee [link]https://docs.example/help?team=[[platform]][/]."
: expectedError;
IReadOnlyList<InteractionMessageAction>? capturedActions = null;
var displayedLogNotification = false;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.MessageActions),
DisplayErrorWithActionsAsyncCallback = (error, actions) =>
{
capturedError = error;
capturedActions = actions;
return Task.CompletedTask;
},
DisplayMessageWithActionsAsyncCallback = (_, _, _) =>
{
displayedLogNotification = true;
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var displayed = await interactionService.TryDisplayCommandFailureAsync(
errorMessage,
cliLogFilePath,
appHostLogFilePath,
allowMarkup,
CancellationToken.None);
await interactionService.FlushAsync();
Assert.True(displayed);
Assert.Equal(expectedError, capturedError);
Assert.Collection(
Assert.IsAssignableFrom<IReadOnlyList<InteractionMessageAction>>(capturedActions),
action =>
{
Assert.Equal(InteractionServiceStrings.OpenCliLog, action.DisplayName);
Assert.Equal(cliLogFilePath, action.FilePath);
},
action =>
{
Assert.Equal(InteractionServiceStrings.OpenAppHostLog, action.DisplayName);
Assert.Equal(appHostLogFilePath, action.FilePath);
});
Assert.False(displayedLogNotification);
}
[Fact]
public async Task TryDisplayCommandFailureAsync_WithoutError_SendsActionableLogMessage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.cli.log");
string? capturedMessage = null;
IReadOnlyList<InteractionMessageAction>? capturedActions = null;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.MessageActions),
DisplayMessageWithActionsAsyncCallback = (_, message, actions) =>
{
capturedMessage = message;
capturedActions = actions;
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var displayed = await interactionService.TryDisplayCommandFailureAsync(
errorMessage: null,
logFilePath,
appHostCliLogFilePath: null,
allowMarkup: false,
CancellationToken.None);
await interactionService.FlushAsync();
Assert.True(displayed);
Assert.Contains(logFilePath, capturedMessage);
var action = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<InteractionMessageAction>>(capturedActions));
Assert.Equal(InteractionServiceStrings.OpenCliLog, action.DisplayName);
Assert.Equal(logFilePath, action.FilePath);
Assert.Null(action.Command);
}
[Fact]
public async Task TryDisplayCommandFailureAsync_WithoutCapability_ReturnsFalseWithoutDisplaying()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var displayedMessage = false;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (_, _) => Task.FromResult(false),
DisplayErrorAsyncCallback = _ =>
{
displayedMessage = true;
return Task.CompletedTask;
},
DisplayErrorWithActionsAsyncCallback = (_, _) =>
{
displayedMessage = true;
return Task.CompletedTask;
},
DisplayMessageAsyncCallback = (_, _) =>
{
displayedMessage = true;
return Task.CompletedTask;
},
DisplayMessageWithActionsAsyncCallback = (_, _, _) =>
{
displayedMessage = true;
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var displayed = await interactionService.TryDisplayCommandFailureAsync(
"The project could not be built.",
Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.cli.log"),
appHostCliLogFilePath: null,
allowMarkup: false,
CancellationToken.None);
await interactionService.FlushAsync();
Assert.False(displayed);
Assert.False(displayedMessage);
}
[Fact]
public async Task SupportsMessageActionsAsync_UsesExtensionCapability()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? requestedCapability = null;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) =>
{
requestedCapability = capability;
return Task.FromResult(true);
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var supported = await interactionService.SupportsMessageActionsAsync(CancellationToken.None);
Assert.True(supported);
Assert.Equal(KnownCapabilities.MessageActions, requestedCapability);
}
[Fact]
public async Task SupportsMessageActionsAsync_WhenCapabilityProbeFails_UsesLegacyNotifications()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (_, _) => throw new InvalidOperationException("Capability probe failed.")
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var supported = await interactionService.SupportsMessageActionsAsync(CancellationToken.None);
Assert.False(supported);
}
[Fact]
public async Task DisplayCancellationMessage_WithCustomMessage_UsesCancellationBackchannel()
{
var output = new StringBuilder();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor,
Out = new AnsiConsoleOutput(new StringWriter(output)),
Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
});
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = workspace.CreateExecutionContext();
var consoleInteractionService = new ConsoleInteractionService(
new ConsoleEnvironment(console, console, TextReader.Null),
executionContext,
TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(),
NullLoggerFactory.Instance,
new ConsoleLogBufferContext());
var cancellationMessageCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var displayMessageCalled = false;
var backchannel = new TestExtensionBackchannel
{
DisplayCancellationMessageAsyncCalled = cancellationMessageCalled,
DisplayMessageAsyncCallback = (_, _) =>
{
displayMessageCalled = true;
return Task.CompletedTask;
}
};
using var extensionInteractionService = new ExtensionInteractionService(
consoleInteractionService,
backchannel,
extensionPromptEnabled: false,
logger: NullLogger<ExtensionInteractionService>.Instance);
extensionInteractionService.DisplayCancellationMessage("Stopping dashboard.");
await extensionInteractionService.FlushAsync();
Assert.True(cancellationMessageCalled.Task.IsCompletedSuccessfully);
Assert.False(displayMessageCalled);
}
[Fact]
public async Task PromptForSelectionAsync_GroupingFallsBackToExistingFlatExtensionChoices()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
SelectionChoice[] choices = [new("Same (same) - first"), new("Same (same) - second")];
IReadOnlyList<string>? capturedChoices = null;
var backchannel = new TestExtensionBackchannel
{
PromptForSelectionAsyncCallback = (_, labels) =>
{
capturedChoices = labels;
return Task.FromResult(labels[1]);
}
};
using var interaction = CreateExtensionInteractionService(workspace, backchannel);
var selected = await interaction.PromptForSelectionAsync("Select:", choices, choice => $"[bold]{choice.Label}[/]",
_ => throw new InvalidOperationException("Flat extension fallback must not evaluate grouping."),
_ => throw new InvalidOperationException("Flat extension fallback must not evaluate terminal labels."));
Assert.Equal(["Same (same) - first", "Same (same) - second"], capturedChoices);
Assert.Same(choices[1], selected);
}
[Fact]
public async Task PromptForSelectionAsync_DisabledExtensionPromptsRetainConsoleGrouping()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
Interactive = InteractionSupport.Yes,
Out = new AnsiConsoleOutput(new StringWriter()),
Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
});
var inputConsole = new TestAnsiConsoleWithInput(console,
[new('\0', ConsoleKey.DownArrow, false, false, false), new('\r', ConsoleKey.Enter, false, false, false)]);
var consoleInteraction = new ConsoleInteractionService(
new ConsoleEnvironment(inputConsole, inputConsole, TextReader.Null),
workspace.CreateExecutionContext(), TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(), NullLoggerFactory.Instance, new ConsoleLogBufferContext());
var backchannel = new TestExtensionBackchannel
{
PromptForSelectionAsyncCallback = (_, _) => throw new InvalidOperationException("Console prompts must not use extension RPC.")
};
using var interaction = new ExtensionInteractionService(consoleInteraction, backchannel,
extensionPromptEnabled: false, logger: NullLogger<ExtensionInteractionService>.Instance);
SelectionChoice[] choices = [new("Same (same) - first"), new("Same (same) - second")];
var groupedChoices = 0;
var selected = await interaction.PromptForSelectionAsync("Select:", choices, choice => choice.Label,
choice =>
{
groupedChoices++;
return new SelectionChoiceGroup(choice.Label, "Source");
}, _ => "Same - Observable services");
Assert.Equal(2, groupedChoices);
Assert.Same(choices[1], selected);
}
[Fact]
public async Task PromptForSelectionsAsync_ForwardsFormattedDefaultsAndMapsSelectedValues()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
SelectionChoice[] choices =
[
new("Aspire skill"),
new("Playwright CLI")
];
string? capturedPrompt = null;
IReadOnlyList<string>? capturedChoices = null;
IReadOnlyList<string>? capturedPreSelected = null;
var backchannel = new TestExtensionBackchannel
{
PromptForSelectionsAsyncCallback = (prompt, formattedChoices, formattedPreSelected) =>
{
capturedPrompt = prompt;
capturedChoices = formattedChoices;
capturedPreSelected = formattedPreSelected;
return Task.FromResult<IReadOnlyList<string>>(["Playwright CLI"]);
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var result = await interactionService.PromptForSelectionsAsync(
"[bold]Select skills:[/]",
choices,
choice => $"[bold]{choice.Label}[/]",
preSelected: [choices[0]]);
Assert.Equal("Select skills:", capturedPrompt);
Assert.Equal(["Aspire skill", "Playwright CLI"], capturedChoices);
Assert.Equal(["Aspire skill"], capturedPreSelected);
Assert.Same(choices[1], Assert.Single(result));
}
[Fact]
public async Task PromptForFilePathAsync_RetriesAfterInvalidSelection()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var invalidPath = Path.Combine(workspace.WorkspaceRoot.FullName, "invalid");
var validPath = Path.Combine(workspace.WorkspaceRoot.FullName, "valid");
var selections = new Queue<string?>([invalidPath, validPath]);
var displayedErrors = new List<string>();
var promptCount = 0;
const string validationMessage = "The selected directory is not available.";
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.FilePickers),
PromptForFilePathAsyncCallback = (_, _, _) =>
{
promptCount++;
return Task.FromResult(selections.Dequeue());
},
DisplayErrorAsyncCallback = error =>
{
displayedErrors.Add(error);
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var result = await interactionService.PromptForFilePathAsync(
"Select a directory",
validator: path => string.Equals(path, validPath, StringComparison.Ordinal)
? ValidationResult.Success()
: ValidationResult.Error(validationMessage),
directory: true,
retryOnValidationFailure: true);
await interactionService.FlushAsync();
Assert.Equal(validPath, result);
Assert.Equal(2, promptCount);
Assert.Equal([validationMessage], displayedErrors);
}
[Fact]
public async Task PromptForFilePathAsync_CancelAfterInvalidSelectionThrows()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var invalidPath = Path.Combine(workspace.WorkspaceRoot.FullName, "invalid");
var selections = new Queue<string?>([invalidPath, null]);
var displayedErrors = new List<string>();
var promptCount = 0;
const string validationMessage = "The selected directory is not available.";
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.FilePickers),
PromptForFilePathAsyncCallback = (_, _, _) =>
{
promptCount++;
return Task.FromResult(selections.Dequeue());
},
DisplayErrorAsyncCallback = error =>
{
displayedErrors.Add(error);
return Task.CompletedTask;
}
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
await Assert.ThrowsAsync<ExtensionOperationCanceledException>(() =>
interactionService.PromptForFilePathAsync(
"Select a directory",
validator: _ => ValidationResult.Error(validationMessage),
directory: true,
retryOnValidationFailure: true));
await interactionService.FlushAsync();
Assert.Equal(2, promptCount);
Assert.Equal([validationMessage], displayedErrors);
}
[Fact]
public async Task PromptForFilePathAsync_InvalidSelectionWithoutRetryThrows()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.FilePickers),
PromptForFilePathAsyncCallback = (_, _, _) => Task.FromResult<string?>("invalid")
};
using var interactionService = CreateExtensionInteractionService(workspace, backchannel);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
interactionService.PromptForFilePathAsync(
"Select a directory",
validator: _ => ValidationResult.Error("Invalid path.")));
Assert.Equal("Invalid path.", exception.Message);
}
[Fact]
public async Task Dispose_StopsBackgroundPump()
{
var output = new StringBuilder();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.TrueColor,
Out = new AnsiConsoleOutput(new StringWriter(output)),
Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
});
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "cli [extension].log");
var executionContext = workspace.CreateExecutionContext(logFilePath: logFilePath);
var consoleInteractionService = new ConsoleInteractionService(
new ConsoleEnvironment(console, console, TextReader.Null),
executionContext,
TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(),
NullLoggerFactory.Instance,
new ConsoleLogBufferContext());
var extensionInteractionService = new ExtensionInteractionService(
consoleInteractionService,
new TestExtensionBackchannel(),
extensionPromptEnabled: false,
logger: NullLogger<ExtensionInteractionService>.Instance);
extensionInteractionService.Dispose();
// The background pump should exit promptly after disposal.
await extensionInteractionService.PumpTask.DefaultTimeout();
}
private static ExtensionInteractionService CreateExtensionInteractionService(
TemporaryWorkspace workspace,
TestExtensionBackchannel backchannel)
{
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(new StringWriter()),
Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
});
console.Profile.Width = int.MaxValue;
var consoleInteractionService = new ConsoleInteractionService(
new ConsoleEnvironment(console, console, TextReader.Null),
workspace.CreateExecutionContext(),
TestHelpers.CreateInteractiveHostEnvironment(),
new EnvironmentProcessPathProvider(),
NullLoggerFactory.Instance,
new ConsoleLogBufferContext());
return new ExtensionInteractionService(
consoleInteractionService,
backchannel,
extensionPromptEnabled: true,
logger: NullLogger<ExtensionInteractionService>.Instance);
}
private sealed record SelectionChoice(string Label);
}