// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
namespace Aspire.Dashboard.Components.Tests.Controls;
public class TextVisualizerDialogTests : DashboardTestContext
{
[Fact]
public async Task Render_TextVisualizerDialog_WithValidJson_FormatsJsonAsync()
{
var rawJson = """
// line comment
[
/* block comment */
1,
{ "test": { "nested": "value" } }
]
""";
var expectedJson = """
/* line comment*/
[
/* block comment */
1,
{
"test": {
"nested": "value"
}
}
]
""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawJson, string.Empty, false), new DialogParameters());
var cut = getCut();
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(expectedJson, instance.TextVisualizerViewModel.FormattedText);
Assert.Equal(DashboardUIHelpers.JsonFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal([DashboardUIHelpers.JsonFormat, DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet());
Assert.Single(cut.FindAll("fluent-dialog-body [slot='title']"));
Assert.Single(cut.FindAll("fluent-dialog-body [slot='title'] svg"));
Assert.Single(cut.FindAll("fluent-dialog-body [slot='title'] .dialog-format"));
Assert.Single(cut.FindAll("fluent-dialog-body [slot='action'] .button-container"));
Assert.Empty(cut.FindAll(".text-visualizer-container .button-container"));
Assert.Empty(cut.FindAll("header"));
}
[Fact]
public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXml_CanChangeFormatAsync()
{
const string rawXml = """<parent><child>text<!-- comment --></child></parent>""";
const string expectedXml =
"""
<parent>
<child>text<!-- comment --></child>
</parent>
""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawXml, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(DashboardUIHelpers.XmlFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal(expectedXml, instance.TextVisualizerViewModel.FormattedText);
Assert.Equal([DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet());
instance.ChangeFormat(DashboardUIHelpers.PlaintextFormat);
Assert.Equal(DashboardUIHelpers.PlaintextFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal(rawXml, instance.TextVisualizerViewModel.FormattedText);
}
[Fact]
public async Task Render_TextVisualizerDialog_FormatPicker_UsesFluentSelectAndPreservesSelectionAfterParentRerenderAsync()
{
const string rawXml = """<parent><child>text<!-- comment --></child></parent>""";
var content = new TextVisualizerDialogViewModel(rawXml, string.Empty, false);
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(content, new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var formatSelect = Assert.Single(cut.FindComponents<FluentSelect<SelectViewModel<string>, SelectViewModel<string>>>());
Assert.NotNull(formatSelect.Find("fluent-dropdown"));
Assert.Equal(Aspire.Dashboard.Resources.Dialogs.TextVisualizerSelectFormatType, formatSelect.Instance.AriaLabel);
Assert.Equal(DashboardUIHelpers.XmlFormat, formatSelect.Instance.Value?.Id);
Assert.Null(formatSelect.Instance.OptionText!(null));
Assert.False(formatSelect.Instance.OptionDisabled!(null));
var formatOptions = formatSelect.Instance.Items ?? throw new InvalidOperationException("Expected format options.");
var plaintextOption = formatOptions.Single(o => o.Id == DashboardUIHelpers.PlaintextFormat);
await formatSelect.InvokeAsync(() => formatSelect.Instance.ValueChanged.InvokeAsync(plaintextOption));
cut.WaitForAssertion(() =>
{
var dialog = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(DashboardUIHelpers.PlaintextFormat, dialog.TextVisualizerViewModel.FormatKind);
Assert.Equal(rawXml, dialog.TextVisualizerViewModel.FormattedText);
Assert.Equal(DashboardUIHelpers.PlaintextFormat, cut.FindComponent<FluentSelect<SelectViewModel<string>, SelectViewModel<string>>>().Instance.Value?.Id);
Assert.Single(cut.FindAll(".text-visualizer-unformatted"));
});
cut.FindComponent<TextVisualizerDialog>().SetParametersAndRender(parameters => parameters.Add(p => p.Content, content));
cut.WaitForAssertion(() =>
{
var dialog = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(DashboardUIHelpers.PlaintextFormat, dialog.TextVisualizerViewModel.FormatKind);
Assert.Equal(rawXml, dialog.TextVisualizerViewModel.FormattedText);
Assert.Equal(DashboardUIHelpers.PlaintextFormat, cut.FindComponent<FluentSelect<SelectViewModel<string>, SelectViewModel<string>>>().Instance.Value?.Id);
});
}
[Fact]
public void Render_TextVisualizer_DisplayUnformatted_AddsUnformattedClass()
{
SetUpDialog(out _);
var cut = RenderComponent<TextVisualizer>(parameters => parameters
.Add(p => p.ViewModel, new TextVisualizerViewModel("""{"value":1}""", indentText: false))
.Add(p => p.DisplayUnformatted, true)
.Add(p => p.Virtualize, false));
Assert.Single(cut.FindAll(".text-visualizer-unformatted"));
}
[Fact]
public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXmlWithDoctypeAsync()
{
const string rawXml = """<?xml version="1.0" encoding="utf-16"?><test>text content</test>""";
const string expectedXml =
"""
<?xml version="1.0" encoding="utf-16"?>
<test>text content</test>
""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawXml, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(DashboardUIHelpers.XmlFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal(expectedXml, instance.TextVisualizerViewModel.FormattedText);
Assert.Equal([DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet());
}
[Fact]
public async Task Render_TextVisualizerDialog_WithInvalidJson_FormatsPlaintextAsync()
{
const string rawText = """{{{{{{"test": 4}""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
Assert.Equal(DashboardUIHelpers.PlaintextFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal(rawText, instance.TextVisualizerViewModel.FormattedText);
Assert.Equal([DashboardUIHelpers.MarkdownFormat, DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet());
}
[Fact]
public async Task Render_TextVisualizerDialog_WithPlaintextUrl_RendersClickableLinkAsync()
{
const string rawText = "See https://aka.ms/aspire/container-runtime-unhealthy for more information.";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var link = cut.Find("a[href='https://aka.ms/aspire/container-runtime-unhealthy']");
Assert.Equal("_blank", link.GetAttribute("target"));
}
[Fact]
public async Task Render_TextVisualizerDialog_WithDifferentThemes_LineClassesChange()
{
var xml = @"<hello><!-- world --></hello>";
var themeManager = new ThemeManager(new TestThemeResolver { EffectiveTheme = "Light" });
var getCut = SetUpDialog(out var dialogService, themeManager: themeManager);
themeManager.EffectiveTheme = "Light";
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(xml, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
Assert.NotEmpty(cut.FindAll(".theme-a11y-light-min"));
themeManager.EffectiveTheme = "Dark";
var instance = cut.FindComponent<TextVisualizerDialog>();
instance.Render();
Assert.NotEmpty(cut.FindAll(".theme-a11y-dark-min"));
}
[Fact]
public async Task Render_TextVisualizerDialog_ResolveTheme_LineClassesChange()
{
var xml = @"<hello><!-- world --></hello>";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(xml, string.Empty, false), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
Assert.NotEmpty(cut.FindAll(".theme-a11y-dark-min"));
}
[Fact]
public async Task Render_TextVisualizerDialog_WithSecret_ShowsWarningFirstOpenAsync()
{
const string rawText = """my text with a secret""";
bool? secretsWarningAcknowledged = null;
var localStorage = new TestLocalStorage { OnSetUnprotectedAsync = (_, o) =>
{
if (o is TextVisualizerDialog.TextVisualizerDialogSettings s)
{
secretsWarningAcknowledged = s.SecretsWarningAcknowledged;
}
}
};
var getCut = SetUpDialog(out var dialogService, localStorage: localStorage);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, ContainsSecret: true), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
Assert.Single(cut.FindAll(".block-warning"));
Assert.False(cut.HasComponent<Virtualize<StringLogLine>>());
cut.Find(".text-visualizer-unmask-content").Click();
cut.WaitForAssertion(() => Assert.False(cut.FindComponent<TextVisualizerDialog>().Instance.ShowSecretsWarning));
Assert.Empty(cut.FindAll(".block-warning"));
Assert.True(cut.HasComponent<Virtualize<StringLogLine>>());
Assert.True(secretsWarningAcknowledged);
}
[Fact]
public async Task Render_TextVisualizerDialog_WithSecret_DoesNotShowWarningIfSetLocallyFirstOpenAsync()
{
const string rawText = """my text with a secret""";
var localStorage = new TestLocalStorage();
localStorage.OnGetUnprotectedAsync = _ => new ValueTuple<bool, object>(true, new TextVisualizerDialog.TextVisualizerDialogSettings(SecretsWarningAcknowledged: true));
var getCut = SetUpDialog(out var dialogService, localStorage: localStorage);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, ContainsSecret: true), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.False(cut.FindComponent<TextVisualizerDialog>().Instance.ShowSecretsWarning));
cut.WaitForAssertion(() => Assert.False(cut.FindComponent<TextVisualizerDialog>().Instance.ShowSecretsWarning));
Assert.False(cut.HasComponent<FluentMessageBar>());
Assert.True(cut.HasComponent<Virtualize<StringLogLine>>());
}
[Fact]
public async Task Render_TextVisualizerDialog_WithSecretAndAsyncSettingsLoad_RendersActionsAfterInitializationAsync()
{
const string rawText = """my text with a secret""";
var localStorage = new TestLocalStorage
{
OnBeforeGetUnprotectedAsync = async _ => await Task.Yield()
};
var getCut = SetUpDialog(out var dialogService, localStorage: localStorage);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, ContainsSecret: true), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".button-container")));
}
[Fact]
public async Task Render_TextVisualizerDialog_WithFixedFormat_UsesFixedFormatAndHidesDropdownAsync()
{
const string rawText = """export VAR=value""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, ContainsSecret: false, FixedFormat: DashboardUIHelpers.PropertiesFormat), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
// Verify the fixed format is used
Assert.Equal(DashboardUIHelpers.PropertiesFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.True(instance.HasFixedFormat);
// Verify the format dropdown is not rendered
Assert.Empty(cut.FindComponents<FluentSelect<SelectViewModel<string>, SelectViewModel<string>>>());
}
[Fact]
public async Task Render_TextVisualizerDialog_WithFixedJsonFormat_UsesJsonFormatAsync()
{
const string rawText = """{"key": "value"}""";
var getCut = SetUpDialog(out var dialogService);
await dialogService.ShowDialogAsync<TextVisualizerDialog>(new TextVisualizerDialogViewModel(rawText, string.Empty, ContainsSecret: false, FixedFormat: DashboardUIHelpers.JsonFormat), new DialogParameters());
var cut = getCut();
cut.WaitForAssertion(() => Assert.True(cut.HasComponent<TextVisualizerDialog>()));
var instance = cut.FindComponent<TextVisualizerDialog>().Instance;
// Verify the fixed format is used
Assert.Equal(DashboardUIHelpers.JsonFormat, instance.TextVisualizerViewModel.FormatKind);
Assert.Equal(
"""
{
"key": "value"
}
""",
instance.TextVisualizerViewModel.FormattedText);
Assert.True(instance.HasFixedFormat);
}
private Func<IRenderedFragment> SetUpDialog(out DashboardDialogService dialogService, ThemeManager? themeManager = null, TestLocalStorage? localStorage = null)
{
FluentUISetupHelpers.SetupDialogInfrastructure(this, themeManager: themeManager, localStorage: localStorage);
var module = JSInterop.SetupModule("/Components/Controls/TextVisualizer.razor.js");
module.SetupVoid();
FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
FluentUISetupHelpers.SetupFluentInputLabel(this);
FluentUISetupHelpers.SetupFluentList(this);
FluentUISetupHelpers.SetupFluentMenu(this);
IRenderedFragment? cut = null;
TestDialogService? testDialogService = null;
testDialogService = new TestDialogService((content, _) =>
{
cut = RenderComponent<CascadingValue<IDialogInstance>>(builder =>
{
builder.Add(p => p.Value, testDialogService!.LastInstance!);
builder.AddChildContent<TextVisualizerDialog>(childBuilder =>
{
childBuilder.Add(p => p.Content, Assert.IsType<TextVisualizerDialogViewModel>(content));
});
});
return Task.CompletedTask;
});
Services.RemoveAll<IDialogService>();
Services.AddSingleton<IDialogService>(testDialogService);
dialogService = new DashboardDialogService(
testDialogService,
new TestStringLocalizer<Aspire.Dashboard.Resources.Dialogs>(),
Services.GetRequiredService<DimensionManager>());
return () => cut ?? throw new InvalidOperationException("The dialog was not rendered.");
}
}