// 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 System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared.DashboardModel;
using Aspire.TestUtilities;
using Deque.AxeCore.Commons;
using Deque.AxeCore.Playwright;
using Google.Protobuf.WellKnownTypes;
using Google.Protobuf.Collections;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using OpenTelemetry.Proto.Trace.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
namespace Aspire.Dashboard.Tests.Integration.Playwright;
[RequiresFeature(TestFeature.Playwright)]
public sealed class ResourcePaneResponsiveTests(ResourcePaneResponsiveTests.MetadataResourcesFixture fixture)
: PlaywrightTestsBase<ResourcePaneResponsiveTests.MetadataResourcesFixture>(fixture)
{
[Theory]
[InlineData(390, false)]
[InlineData(390, true)]
[InlineData(768, false)]
[InlineData(768, true)]
[InlineData(769, false)]
[InlineData(769, true)]
[InlineData(1440, false)]
[InlineData(1440, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task Accessibility_PaneAndPopoversHaveNoBlockingWcagViolations(int width, bool dark)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.GotoAsync("/resources/cache?pane=tags");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.SetViewportSizeAsync(width, width == 768 ? 1024 : width == 390 ? 844 : 900);
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='cache']").First).ToBeVisibleAsync();
foreach (var canvas in width <= 768 ? new[] { false } : new[] { false, true })
{
if (canvas)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
}
foreach (var surface in new[] { "pane", "filter", "grouping", "context" })
{
if (surface != "pane")
{
if (surface == "context")
{
await page.Locator(".resource-row[data-resource-name='cache'] .resource-row-link").First.ClickAsync(new() { Button = MouseButton.Right });
}
else
{
await page.Locator(surface == "filter" ? "#resourcePaneFilterButton" : "#resourcePaneGroupingButton").ClickAsync();
}
await Assertions.Expect(surface == "filter" ? page.Locator(".resource-filter") :
page.Locator("fluent-menu-list:popover-open")).ToBeVisibleAsync();
}
var result = await page.RunAxe(new AxeRunOptions
{
RunOnly = new RunOnlyOptions { Type = "tag", Values = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] }
});
var blocking = result.Violations.Where(v => v.Impact is "serious" or "critical").ToArray();
// AxeSelector.Selector throws for shadow-DOM targets. Report the actual
// failing HTML instead of serializing that convenience property.
var report = new
{
Passes = result.Passes.Length,
Violations = result.Violations.Select(v => new
{
v.Id,
v.Impact,
v.Help,
Nodes = v.Nodes.Select(n => n.Html)
}),
Incomplete = result.Incomplete.Select(v => v.Id)
};
await File.WriteAllTextAsync(Path.Combine(AppContext.BaseDirectory, $"axe-pane-{width}-{dark}-{canvas}-{surface}.json"),
JsonSerializer.Serialize(report));
Assert.True(blocking.Length == 0, JsonSerializer.Serialize(report.Violations));
if (surface != "pane")
{
await page.Keyboard.PressAsync("Escape");
}
}
}
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task EndpointTypography_UsesSecondaryDensityAndKeepsRowRhythm(bool dark)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(2000, 1000);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.GotoAsync("/resources/cache?pane=tags");
foreach (var (density, fontSize, lineHeight, rowHeight) in new[]
{ ("Compact", 11d, 16d, 44d), ("Comfortable", 12d, 18d, 52d), ("Spacious", 14d, 20d, 60d) })
{
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
var address = page.Locator(".resource-row[data-resource-name='api'] .resource-row-endpoint-address").First;
await Assertions.Expect(address).ToBeVisibleAsync();
var metrics = await address.EvaluateAsync<double[]>("""
address => {
const row=address.closest('.resource-row'),source=row.querySelector('.resource-row-source-value'),
time=row.querySelector('.resource-row-start-time time'),a=getComputedStyle(address),
s=getComputedStyle(source),t=getComputedStyle(time),b=address.getBoundingClientRect(),
sb=source.getBoundingClientRect(),tb=time.getBoundingClientRect();
return [parseFloat(a.fontSize),parseFloat(a.lineHeight),parseFloat(a.fontWeight),
parseFloat(s.fontSize),parseFloat(t.fontSize),b.y+b.height/2-sb.y-sb.height/2,
b.y+b.height/2-tb.y-tb.height/2,row.getBoundingClientRect().height,
a.color===s.color?1:0,a.textOverflow==='ellipsis'?1:0];
}
""");
Assert.Equal([fontSize, lineHeight, 400d, fontSize, fontSize], metrics[..5]);
Assert.All(metrics[5..7], value => Assert.InRange(Math.Abs(value), 0, .5));
Assert.Equal(rowHeight, metrics[7]);
Assert.Equal([1d, 1d], metrics[8..]);
await Assertions.Expect(address).ToHaveAttributeAsync("data-tooltip",
DashboardUIHelpers.MaskQueryStringValues(MetadataResourcesFixture.Endpoint));
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
}
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task PaneWidth_ChangesOnlyAfterExplicitResizeAndSurvivesCacheBypassReload()
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
await page.AddInitScriptAsync($$$"""
(() => {
const widthKey={{{JsonSerializer.Serialize(BrowserStorageKeys.ResourcePaneWidth)}}};
if(localStorage.getItem(widthKey)===null)localStorage.setItem(widthKey,'507');
window.paneWidthWrites=[];
const setItem=Storage.prototype.setItem;
Storage.prototype.setItem=function(key,value) {
if(key===widthKey)window.paneWidthWrites.push(value);
return setItem.call(this,key,value);
};
})();
""");
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.GotoAsync("/resources/cache?pane=tags");
var resizer = page.Locator(".resource-pane-resizer");
await AssertSavedWidthAsync(507);
await fixture.PublishAsync([
new(ResourceViewModelChangeType.Upsert, MetadataResourcesFixture.CreateDynamicChild(91)),
new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource("api",
commands: [new("inspect", CommandViewModelState.Enabled, "Inspect", "", "", [],
isHighlighted: true, iconName: "Search", iconVariant: Microsoft.FluentUI.AspNetCore.Components.IconVariant.Regular)]))
]);
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='rebuilder-91']")).ToBeVisibleAsync();
await AssertSavedWidthAsync(507);
var kinds = page.Locator(".resource-kind-toggle[aria-label='Parameters']");
await kinds.ClickAsync();
await AssertSavedWidthAsync(507);
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "Resource type", Exact = true }).ClickAsync();
await AssertSavedWidthAsync(507);
await page.Locator("#resourcePaneFilterButton").ClickAsync();
await page.Keyboard.PressAsync("Escape");
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await AssertSavedWidthAsync(507);
var collapse = page.Locator(".resource-header-row .resource-pane-toggle-button");
await collapse.ClickAsync();
await collapse.ClickAsync();
await AssertSavedWidthAsync(507);
await page.Locator(".resource-row[data-resource-name='api'] .resource-row-link").ClickAsync();
await AssertSavedWidthAsync(507);
Assert.Empty(await page.EvaluateAsync<string[]>("window.paneWidthWrites"));
var session = await page.Context.NewCDPSessionAsync(page);
await session.SendAsync("Network.setCacheDisabled", new Dictionary<string, object> { ["cacheDisabled"] = true });
await page.ReloadAsync();
await AssertSavedWidthAsync(507);
Assert.Empty(await page.EvaluateAsync<string[]>("window.paneWidthWrites"));
await session.SendAsync("Network.setCacheDisabled", new Dictionary<string, object> { ["cacheDisabled"] = false });
await page.ReloadAsync();
await AssertSavedWidthAsync(507);
Assert.Empty(await page.EvaluateAsync<string[]>("window.paneWidthWrites"));
await session.DetachAsync();
await resizer.DblClickAsync();
await AssertSavedWidthAsync(420);
await page.ReloadAsync();
await AssertSavedWidthAsync(420);
Assert.Empty(await page.EvaluateAsync<string[]>("window.paneWidthWrites"));
async Task AssertSavedWidthAsync(int width)
{
await Assertions.Expect(resizer).ToHaveAttributeAsync("aria-disabled", "false");
await Assertions.Expect(resizer).ToHaveAttributeAsync("aria-valuenow", width.ToString(CultureInfo.InvariantCulture));
Assert.Equal(width, await page.Locator(".resource-pane").EvaluateAsync<double>("e=>e.getBoundingClientRect().width"));
await AsyncTestHelpers.AssertIsTrueRetryAsync(
async () => await page.EvaluateAsync<string?>(
$"localStorage.getItem({JsonSerializer.Serialize(BrowserStorageKeys.ResourcePaneWidth)})") == width.ToString(CultureInfo.InvariantCulture),
"Explicit resource pane width wasn't retained.");
}
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task PopulatedActivity_BoundsIdleAndRefreshRenderingAndResumesWithoutAnimationFrames()
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
await page.AddInitScriptAsync("""
(() => {
window.activitySocketStats={frames:0,bytes:0,renderFrames:0};
const Socket=window.WebSocket;
window.WebSocket=class extends Socket {
constructor(...args) {
super(...args);
this.addEventListener('message',event=>{
const stats=window.activitySocketStats;
const bytes=typeof event.data==='string' ? new TextEncoder().encode(event.data) :
event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : null;
if(!bytes)return;
stats.frames++;stats.bytes+=bytes.length;
if(new TextDecoder().decode(bytes).includes('JS.RenderBatch'))stats.renderFrames++;
});
}
};
})();
""");
await page.SetViewportSizeAsync(2000, 1000);
await page.GotoAsync("/resources?pane=tags");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
var resources = Enumerable.Range(0, 28).Select(MetadataResourcesFixture.CreatePerformanceResource).ToArray();
await fixture.PublishAsync([
.. MetadataResourcesFixture.CreateResources().Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Delete, resource)),
.. resources.Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource))
]);
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(56);
await fixture.SeedActivityAsync(resources.Select(resource => (resource.Name, 20)).ToArray());
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".resource-row-insight-requests")).ToHaveCountAsync(56);
await Assertions.Expect(page.Locator(".resource-row-insight-latency")).ToHaveCountAsync(56);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.WaitForFunctionAsync("""
() => !document.querySelector('.resources-layout').getAnimations({subtree:true})
.some(animation=>animation.playState==='running')
""");
await page.EvaluateAsync("""
() => {
window.activityMeasure={mutations:0,rows:[],longTasks:[],shifts:[]};
const rows=new Set();
window.activityMutationObserver=new MutationObserver(records=>{
window.activityMeasure.mutations+=records.length;
for(const record of records) {
const element=record.target.nodeType===1 ? record.target : record.target.parentElement;
const row=element.closest('.resource-row[data-resource-name]');
if(row)rows.add(row.dataset.resourceName);
}
window.activityMeasure.rows=[...rows];
});
window.activityMutationObserver.observe(document.querySelector('.resource-pane-list'),
{subtree:true,childList:true,attributes:true,characterData:true});
new PerformanceObserver(list=>window.activityMeasure.longTasks.push(...list.getEntries().map(e=>e.duration)))
.observe({type:'longtask'});
new PerformanceObserver(list=>window.activityMeasure.shifts.push(...list.getEntries().map(e=>e.value)))
.observe({type:'layout-shift'});
window.activitySocketStats={frames:0,bytes:0,renderFrames:0};
window.resetActivityMeasure=()=>{
rows.clear();
window.activityMeasure={mutations:0,rows:[],longTasks:[],shifts:[]};
window.activitySocketStats={frames:0,bytes:0,renderFrames:0};
};
}
""");
await page.EvaluateAsync("() => new Promise(resolve=>setTimeout(resolve,10000))");
var idle = await MeasureAsync();
await page.EvaluateAsync("() => window.resetActivityMeasure()");
await fixture.SeedActivityAsync(resources.Select(resource => (resource.Name, 21)).ToArray());
await Assertions.Expect(page.Locator(".resource-row-insight-requests > span")).ToHaveTextAsync(Enumerable.Repeat("21 incoming", 56));
await page.EvaluateAsync("() => new Promise(resolve=>setTimeout(resolve,500))");
var refresh = await MeasureAsync();
await File.WriteAllTextAsync(Path.Combine(AppContext.BaseDirectory, "activity-performance.json"),
JsonSerializer.Serialize(new { idle, refresh, resources = 28, memberships = 56 }, new JsonSerializerOptions { WriteIndented = true }));
Assert.InRange(idle.GetProperty("mutations").GetInt32(), 0, 2);
Assert.InRange(refresh.GetProperty("mutations").GetInt32(), 1, 56 * 16);
Assert.Equal(28, refresh.GetProperty("rows").GetArrayLength());
foreach (var sample in new[] { idle, refresh })
{
Assert.Empty(sample.GetProperty("longTasks").EnumerateArray());
Assert.Equal(0, sample.GetProperty("shifts").EnumerateArray().Sum(value => value.GetDouble()));
}
var href = await page.Locator(".resource-row-insight-requests").First.GetAttributeAsync("href");
await page.EvaluateAsync("""
() => {
window.savedActivityAnimationFrame=window.requestAnimationFrame;
window.requestAnimationFrame=()=>0;
Object.defineProperty(document,'visibilityState',{configurable:true,get:()=> 'hidden'});
document.dispatchEvent(new Event('visibilitychange'));
}
""");
await Assertions.Expect(page.Locator(".resource-row-insight")).ToHaveCountAsync(0);
await page.EvaluateAsync("""
() => {
window.requestAnimationFrame=window.savedActivityAnimationFrame;
delete document.visibilityState;
document.dispatchEvent(new Event('visibilitychange'));
}
""");
await Assertions.Expect(page.Locator(".resource-row-insight-requests")).ToHaveCountAsync(56);
Assert.NotEqual(href, await page.Locator(".resource-row-insight-requests").First.GetAttributeAsync("href"));
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, "activity-populated-28-resources.png") });
Task<JsonElement> MeasureAsync() => page.EvaluateAsync<JsonElement>(
"() => ({...window.activityMeasure,socket:{...window.activitySocketStats}})");
});
}
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("fr-FR")]
[InlineData("ja-JP")]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task StartTime_RemainsCompleteAndAccessibleAcrossCulturesAndWidths(string culture)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(2000, 1000);
await page.GotoAsync("/resources/api?pane=tags");
var origin = new Uri(page.Url).GetLeftPart(UriPartial.Authority);
foreach (var density in new[] { "Compact", "Comfortable", "Spacious" })
{
await SetCultureAsync("en-US");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await SetCultureAsync(culture);
await Assertions.Expect(page.Locator("html")).ToHaveAttributeAsync("lang", culture);
await fixture.PublishAsync([new(ResourceViewModelChangeType.Upsert, MetadataResourcesFixture.CreateDynamicChild(0))]);
await Assertions.Expect(page.Locator("[data-resource-name='rebuilder-0']")).ToBeVisibleAsync();
foreach (var width in new[] { 420, 560, 769, 1279, 1280, 1440, 2000 })
{
if (await page.Locator(".pane-full-canvas").CountAsync() > 0)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
}
await page.SetViewportSizeAsync(width <= 560 ? 2000 : width, 1000);
if (width <= 560)
{
await page.Locator(".resource-pane-resizer").PressAsync(width == 420 ? "Home" : "End");
await Assertions.Expect(page.Locator(".resource-pane-resizer"))
.ToHaveAttributeAsync("aria-valuenow", width.ToString(CultureInfo.InvariantCulture));
}
else
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
var chrome = await page.EvaluateAsync<double>("() => innerWidth - document.querySelector('.resources-layout').getBoundingClientRect().width");
await page.SetViewportSizeAsync(width + (int)Math.Round(chrome), 1000);
}
await page.WaitForFunctionAsync("""
width => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - width) < .5
""", width);
var problems = await page.Locator(".resource-pane").EvaluateAsync<string[]>("""
pane => [...pane.querySelectorAll('time')].flatMap(time => {
const b=time.getBoundingClientRect();
if(!b.width)return [];
const visible=time.innerText.replace(/\s+/g,' ').trim(), container=time.parentElement.getBoundingClientRect();
return time.scrollWidth>time.clientWidth || b.right>container.right+.5 ||
time.tabIndex!==0 || !time.getAttribute('aria-label')?.includes(visible) ||
!time.getAttribute('data-tooltip')?.includes(visible) ||
!time.getAttribute('aria-label')?.includes('2026')
? [`${time.closest('.resource-row').dataset.resourceName}: ${visible}; width=${b.width}; scroll=${time.scrollWidth}; parent=${container.width}`] : [];
})
""");
Assert.True(problems.Length == 0, $"{culture}/{density}/{width}: {string.Join("; ", problems)}");
}
}
async Task SetCultureAsync(string name)
{
await page.Context.AddCookiesAsync([new() { Name = ".AspNetCore.Culture", Value = $"c={name}|uic={name}", Url = origin }]);
fixture.StartDynamicSession();
await page.GotoAsync("/resources/api?pane=tags");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='api']").First).ToBeVisibleAsync();
}
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task Resizer_DoubleClickRestoresMinimumAcrossPresentationAndNavigation(bool reducedMotion)
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
await page.EmulateMediaAsync(new() { ReducedMotion = reducedMotion ? ReducedMotion.Reduce : ReducedMotion.NoPreference });
await page.SetViewportSizeAsync(1440, 1000);
await page.GotoAsync("/resources/cache?pane=tags");
var separator = page.Locator(".resource-pane-resizer");
await Assertions.Expect(separator).ToBeVisibleAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-disabled", "false");
foreach (var stage in new[] { "drag", "density", "filter-group", "canvas", "traces", "overview" })
{
if (stage == "density")
{
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Spacious", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await Assertions.Expect(page.GetByRole(AriaRole.Dialog)).ToHaveCountAsync(0);
await Assertions.Expect(page.Locator("#dashboard-settings-button")).ToBeFocusedAsync();
}
else if (stage == "filter-group")
{
await page.Locator(".resource-pane").GetByRole(AriaRole.Searchbox).FillAsync("cache");
await page.Locator(".resource-pane").GetByRole(AriaRole.Searchbox).FillAsync("");
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator("#resourcePaneGroupingButton")).ToBeFocusedAsync();
}
else if (stage == "canvas")
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
}
else if (stage is "traces" or "overview")
{
await page.GetByRole(AriaRole.Tab, new() { Name = stage == "traces" ? "Traces" : "Overview", Exact = true }).ClickAsync();
if (stage == "traces")
{
await Assertions.Expect(page.Locator("#tracesScrollContainer")).ToBeFocusedAsync();
}
}
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-disabled", "false");
await separator.FocusAsync();
await separator.PressAsync("End");
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "560");
await page.WaitForFunctionAsync("() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - 560) < .5");
if (stage == "drag")
{
var box = await separator.BoundingBoxAsync();
Assert.NotNull(box);
await page.Mouse.MoveAsync(box.X + box.Width / 2, box.Y + box.Height / 2);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(box.X + box.Width / 2 - 60, box.Y + box.Height / 2, new() { Steps = 5 });
await page.Mouse.UpAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "500");
}
var url = page.Url;
await separator.FocusAsync();
await separator.DblClickAsync(new() { Delay = 80 });
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "420");
await Assertions.Expect(separator).ToBeFocusedAsync();
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='cache'] .resource-row-link").First)
.ToHaveAttributeAsync("aria-current", "page");
await page.WaitForFunctionAsync("""
() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - 420) < .5 &&
document.querySelector('.resources-layout').style.getPropertyValue('--resource-pane-expanded-width') === '420px' &&
localStorage.getItem('Aspire_ResourcePane_Width') === '420'
""");
Assert.Equal(url, page.Url);
}
await page.ReloadAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "420");
await page.WaitForFunctionAsync("() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - 420) < .5");
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task SelectionAndResize_PreserveAvailableTracesTab(bool reducedMotion)
{
await RunTestAsync(async page =>
{
await fixture.SeedActivityAsync([("cache", 20), ("api", 20), ("frontend", 20)]);
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = reducedMotion ? ReducedMotion.Reduce : ReducedMotion.NoPreference });
await page.GotoAsync("/resources/cache?pane=tags");
var tracesTab = page.GetByRole(AriaRole.Tab, new() { Name = "Traces", Exact = true });
if (reducedMotion)
{
await tracesTab.FocusAsync();
await tracesTab.PressAsync("Enter");
}
else
{
await tracesTab.ClickAsync();
}
await Assertions.Expect(page.Locator("#tracesScrollContainer")).ToBeFocusedAsync();
foreach (var name in new[] { "api", "frontend", "cache" })
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
await page.Locator($".resource-row[data-resource-name='{name}'] .resource-row-link").First.ClickAsync();
await page.WaitForURLAsync($"**/traces/resource/{name}");
await Assertions.Expect(page.Locator(".pane-full-canvas,.pane-selection-reveal")).ToHaveCountAsync(0);
var separator = page.Locator(".resource-pane-resizer");
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-disabled", "false");
await separator.PressAsync("End");
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "560");
await separator.DblClickAsync(new() { Delay = 80 });
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", "420");
await Assertions.Expect(separator).ToBeFocusedAsync();
Assert.EndsWith($"/traces/resource/{name}", page.Url, StringComparison.Ordinal);
await Assertions.Expect(page.GetByRole(AriaRole.Tab, new() { Name = "Traces", Exact = true }))
.ToHaveAttributeAsync("aria-selected", "true");
}
});
}
[Theory]
[InlineData(390, 844, false)]
[InlineData(768, 1024, false)]
[InlineData(1440, 900, false)]
[InlineData(1440, 900, true)]
[InlineData(2000, 1000, false)]
[InlineData(2000, 1000, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task FilterAndGrouping_StayInsideVisualViewportAcrossPaneModesAndResize(int width, int height, bool fullCanvas)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(width, height);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.GotoAsync("/resources/cache?pane=tags");
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
if (fullCanvas)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
}
foreach (var filter in new[] { true, false })
{
var button = page.Locator(filter ? "#resourcePaneFilterButton" : "#resourcePaneGroupingButton");
await button.HoverAsync();
var tooltip = page.Locator(".dashboard-tooltip").Last;
await Assertions.Expect(tooltip).ToBeVisibleAsync();
await Assertions.Expect(tooltip).ToHaveAttributeAsync("positioning", "above");
await AssertContainedAsync(tooltip);
await button.ClickAsync();
var surface = filter ? page.Locator(".resource-filter-popover[part='dialog']") :
page.Locator("fluent-menu-list:popover-open");
await Assertions.Expect(surface).ToBeVisibleAsync();
if (filter)
{
while (await page.Locator(".resource-filter-group-header[aria-expanded='false']").CountAsync() > 0)
{
await page.Locator(".resource-filter-group-header[aria-expanded='false']").First.ClickAsync();
}
}
await AssertContainedAsync(surface);
await page.SetViewportSizeAsync(width - 40, 260);
await AssertContainedAsync(surface);
if (filter)
{
await AssertContainedAsync(page.Locator(".resource-filter-footer"));
Assert.True(await page.Locator(".resource-filter-body").EvaluateAsync<bool>(
"el => el.scrollHeight>el.clientHeight&&getComputedStyle(el).overflowY==='auto'"));
await page.Locator(".resource-filter-body").EvaluateAsync("el=>el.scrollTop=el.scrollHeight");
await AssertContainedAsync(page.Locator(".resource-filter-done"));
// Measure native dismissal in the same JavaScript turn, before Blazor can handle the close event.
var dismissal = await surface.EvaluateAsync<int[]>("""
dialog => {
const wasOpen = dialog.matches(':popover-open');
dialog.hidePopover();
return [wasOpen ? 1 : 0, dialog.matches(':popover-open') ? 1 : 0, dialog.getClientRects().length];
}
""");
Assert.Equal([1, 0, 0], dismissal);
await Assertions.Expect(button).ToHaveAttributeAsync("aria-expanded", "false");
await button.ClickAsync();
await Assertions.Expect(surface).ToBeVisibleAsync();
}
else
{
Assert.Equal("auto", await surface.EvaluateAsync<string>("el=>getComputedStyle(el).overflowY"));
var item = surface.Locator("fluent-menu-item").Last;
await item.ScrollIntoViewIfNeededAsync();
await AssertContainedAsync(item);
}
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(surface).Not.ToBeVisibleAsync();
await Assertions.Expect(button).ToBeFocusedAsync();
await page.SetViewportSizeAsync(width, height);
}
static async Task AssertContainedAsync(ILocator surface)
{
var geometry = await surface.EvaluateAsync<double[]>("""
el => {
const b=el.getBoundingClientRect(),v=window.visualViewport;
return [b.left-v.offsetLeft,b.top-v.offsetTop,v.offsetLeft+v.width-b.right,
v.offsetTop+v.height-b.bottom,b.width,b.height,
document.documentElement.scrollWidth-document.documentElement.clientWidth];
}
""");
Assert.All(geometry[..4], value => Assert.True(value >= -.5, string.Join(", ", geometry)));
Assert.True(geometry[4] > 0 && geometry[5] > 0);
Assert.InRange(geometry[6], 0, 1);
}
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task SelectionHint_RotatesWithoutLayoutChangesAndPausesWhenInactive(bool reducedMotion)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = reducedMotion ? ReducedMotion.Reduce : ReducedMotion.NoPreference });
await page.GotoAsync("/resources/cache?pane=tags");
var hint = page.Locator(".resource-selection-hint");
await Assertions.Expect(hint).ToBeVisibleAsync();
var before = await hint.BoundingBoxAsync();
Assert.NotNull(before);
var phases = await hint.EvaluateAsync<double[][]>("""
hint => [1000,6000].map(time => {
const groups=[...hint.querySelectorAll('.resource-selection-instruction')];
groups.flatMap(group=>group.getAnimations()).forEach(animation=>animation.currentTime=time);
const b=hint.getBoundingClientRect();
return [...groups.map(group=>Number(getComputedStyle(group).opacity)),b.x,b.y,b.width,b.height];
})
""");
Assert.Equal(1, phases[0][0]);
Assert.Equal(0, phases[0][1]);
Assert.Equal(0, phases[1][0]);
Assert.Equal(1, phases[1][1]);
Assert.Equal(phases[0][2..], phases[1][2..]);
Assert.InRange(before.Height, 0, 18);
await hint.HoverAsync();
Assert.True(await hint.EvaluateAsync<bool>("el => [...el.querySelectorAll('.resource-selection-instruction')].every(g=>g.getAnimations().every(a=>a.playState==='paused'))"));
await hint.FocusAsync();
await page.Mouse.MoveAsync(1, 1);
Assert.True(await hint.EvaluateAsync<bool>("el => [...el.querySelectorAll('.resource-selection-instruction')].every(g=>getComputedStyle(g).animationPlayState==='paused')"));
await hint.PressAsync("Tab");
await page.EvaluateAsync("""
() => {
Object.defineProperty(document,'visibilityState',{configurable:true,value:'hidden'});
document.dispatchEvent(new Event('visibilitychange'));
}
""");
await Assertions.Expect(page.Locator(".resources-layout")).ToHaveAttributeAsync("data-document-hidden", "");
Assert.True(await hint.EvaluateAsync<bool>("el => [...el.querySelectorAll('.resource-selection-instruction')].every(g=>getComputedStyle(g).animationPlayState==='paused')"));
await page.EvaluateAsync("""
() => {
delete document.visibilityState;
document.dispatchEvent(new Event('visibilitychange'));
}
""");
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await page.WaitForFunctionAsync("() => document.querySelector('.resource-pane').getBoundingClientRect().width===1440");
Assert.True(await hint.EvaluateAsync<bool>("el => [...el.querySelectorAll('.resource-selection-instruction')].every(g=>g.getAnimations().length===0&&getComputedStyle(g).opacity==='1')"));
var after = await hint.BoundingBoxAsync();
Assert.NotNull(after);
Assert.InRange(after.Height, 0, 18);
Assert.Equal(2, await hint.Locator("[aria-hidden='true']").CountAsync());
});
}
[Theory]
[InlineData("Windows", "MacIntel", 0, false)]
[InlineData("macOS", "Win32", 0, true)]
[InlineData(null, "MacIntel", 5, true)]
[InlineData(null, "iPad", 0, true)]
[InlineData(null, "iPhone", 0, true)]
[InlineData(null, "Win32", 0, false)]
[InlineData(null, "Linux x86_64", 0, false)]
[InlineData("", "MacIntel", 0, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task SelectionHint_ResolvesPlatformBeforeDisplayingModifier(string? userAgentPlatform, string platform, int touchPoints, bool command)
{
await RunTestAsync(async page =>
{
await page.AddInitScriptAsync($$$"""
(() => {
const data={{{JsonSerializer.Serialize(new { userAgentPlatform, platform, touchPoints })}}};
Object.defineProperty(navigator,'userAgentData',{value:data.userAgentPlatform===null ? undefined : {platform:data.userAgentPlatform}});
Object.defineProperty(navigator,'platform',{value:data.platform});
Object.defineProperty(navigator,'maxTouchPoints',{value:data.touchPoints});
window.selectionHintHistory=[];
new MutationObserver(() => {
const text=document.querySelector('.resource-selection-hint kbd')?.textContent;
if(text && window.selectionHintHistory.at(-1)!==text) window.selectionHintHistory.push(text);
}).observe(document,{subtree:true,childList:true,characterData:true});
})();
""");
await page.SetViewportSizeAsync(1440, 900);
await page.GotoAsync("/resources/cache?pane=tags");
var hint = page.Locator(".resource-selection-hint");
await Assertions.Expect(hint).ToBeVisibleAsync();
var expected = command ? "⌘ + click" : "Ctrl + click";
await Assertions.Expect(hint.Locator("kbd").First).ToHaveTextAsync(expected);
await Assertions.Expect(hint).ToHaveAttributeAsync("aria-label", command
? Resources.Layout.ResourcePaneSelectionCommandDescription
: Resources.Layout.ResourcePaneSelectionControlDescription);
Assert.Equal([expected], await page.EvaluateAsync<string[]>("() => window.selectionHintHistory"));
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task IconTile_PreservesStylesAcrossPaneModesAndDensities(bool dark)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await page.GotoAsync("/resources/cache?pane=tags");
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name='cache']").First).ToBeVisibleAsync();
await Assertions.Expect(page.Locator(".resource-row:not(.selected)[data-resource-name]").First).ToBeVisibleAsync();
foreach (var density in new[] { "Compact", "Comfortable", "Spacious" })
{
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
var before = await ReadTilesAsync();
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
Assert.Equal(before, await ReadTilesAsync());
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToHaveCountAsync(0);
Assert.Equal(before, await ReadTilesAsync());
}
Task<string> ReadTilesAsync() => page.Locator(".resource-pane-list").EvaluateAsync<string>("""
list => JSON.stringify([...list.querySelectorAll('.resource-row[data-resource-name]')].map(row => {
const icon=row.querySelector('.resource-row-icon'), glyph=icon.querySelector('.resource-row-glyph svg');
const b=icon.getBoundingClientRect(), r=row.getBoundingClientRect(), g=glyph.getBoundingClientRect();
const badge=icon.querySelector('.persistent-container-badge')?.getBoundingClientRect(), style=getComputedStyle(icon);
return {name:row.dataset.resourceName,selected:row.classList.contains('selected'),
width:b.width,height:b.height,radius:style.borderRadius,background:style.backgroundColor,
border:style.border,glyphWidth:g.width,glyphHeight:g.height,
color:getComputedStyle(glyph).color,fill:getComputedStyle(glyph).fill,
center:b.y+b.height/2-r.y-r.height/2,
badge:badge ? {x:badge.x-b.x,y:badge.y-b.y,width:badge.width,height:badge.height} : null};
}))
""");
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task SelectionSurfaces_UseDashboardNeutralTokensAcrossThemes(bool dark)
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
await page.SetViewportSizeAsync(1440, 1000);
await page.GotoAsync("/resources/api?pane=tags");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.Mouse.MoveAsync(1, 1);
var selected = page.Locator(".resource-row[data-resource-name='api']").First;
var rest = page.Locator(".resource-row[data-resource-name='cache']").First;
var bounds = await selected.BoundingBoxAsync();
Assert.NotNull(bounds);
await AssertSurfaceAsync(rest, null);
await AssertSurfaceAsync(selected, "--colorNeutralBackground1");
await rest.HoverAsync();
await AssertSurfaceAsync(rest, "--colorNeutralBackground3");
await selected.HoverAsync();
await AssertSurfaceAsync(selected, "--colorNeutralBackground3");
await page.Mouse.DownAsync();
var pressed = await selected.EvaluateAsync<string[]>("""
row => {
const ctx=document.createElement('canvas').getContext('2d'),style=getComputedStyle(row);
ctx.fillStyle=style.getPropertyValue('--colorNeutralBackground1Pressed');
const expected=ctx.fillStyle;ctx.fillStyle=style.backgroundColor;
return [expected,ctx.fillStyle];
}
""");
Assert.Equal(pressed[0], pressed[1]);
await page.Mouse.MoveAsync(1, 1);
await page.Mouse.UpAsync();
await selected.Locator(".resource-row-link").FocusAsync();
await page.Keyboard.PressAsync("Tab");
await page.Keyboard.PressAsync("Shift+Tab");
await Assertions.Expect(selected.Locator(".resource-row-link")).ToBeFocusedAsync();
Assert.True(await selected.EvaluateAsync<bool>("row => row.matches(':has(> .resource-row-link:focus-visible)')"));
Assert.Equal("2px", await selected.EvaluateAsync<string>("row => getComputedStyle(row).outlineWidth"));
await AssertSurfaceAsync(selected, "--colorNeutralBackground1");
var selectAll = page.GetByRole(AriaRole.Button, new() { Name = "Select all", Exact = true });
await selectAll.FocusAsync();
await selectAll.PressAsync("Space");
await Assertions.Expect(selectAll).ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(8);
foreach (var row in await page.Locator(".resource-row[data-resource-name]").AllAsync())
{
await AssertSurfaceAsync(row, "--colorNeutralBackground1");
}
var selectedBounds = await selected.BoundingBoxAsync();
Assert.NotNull(selectedBounds);
Assert.Equal(new[] { bounds.X, bounds.Y, bounds.Width, bounds.Height },
new[] { selectedBounds.X, selectedBounds.Y, selectedBounds.Width, selectedBounds.Height });
await rest.Locator(".resource-row-link").ClickAsync();
await Assertions.Expect(selectAll).Not.ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
Assert.Null(await selectAll.GetAttributeAsync("aria-description"));
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name='cache']")).ToHaveCountAsync(2);
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(2);
await page.Mouse.MoveAsync(1, 1);
await AssertSurfaceAsync(rest, "--colorNeutralBackground1");
await AssertSurfaceAsync(selected, null);
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
await page.Mouse.MoveAsync(1, 1);
await AssertSurfaceAsync(rest, "--colorNeutralBackground1");
await AssertSurfaceAsync(selected, null);
await page.EmulateMediaAsync(new() { ForcedColors = ForcedColors.Active });
Assert.True(await rest.EvaluateAsync<bool>("""
row => {
const probe=document.createElement('span');
probe.style.background='Highlight'; row.append(probe);
const matches=getComputedStyle(probe).backgroundColor===getComputedStyle(row).backgroundColor;
probe.remove(); return matches;
}
"""));
await page.EmulateMediaAsync(new() { ForcedColors = ForcedColors.None });
static async Task AssertSurfaceAsync(ILocator row, string? token)
{
var sample = await row.EvaluateAsync<JsonElement>("""
(row, token) => {
const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d',{willReadFrequently:true});
canvas.width=canvas.height=1;
const style=getComputedStyle(row), ancestors=[];
for(let e=row.parentElement;e;e=e.parentElement)ancestors.unshift(e);
ctx.fillStyle='white';ctx.fillRect(0,0,1,1);
for(const e of ancestors){ctx.fillStyle=getComputedStyle(e).backgroundColor;ctx.fillRect(0,0,1,1);}
const surface=ctx.getImageData(0,0,1,1);
ctx.fillStyle=style.backgroundColor;ctx.fillRect(0,0,1,1);
const actual=[...ctx.getImageData(0,0,1,1).data].slice(0,3);
ctx.putImageData(surface,0,0);
if(token){ctx.fillStyle=style.getPropertyValue(token);ctx.fillRect(0,0,1,1);}
const expected=[...ctx.getImageData(0,0,1,1).data].slice(0,3);
return {actual,expected,background:style.backgroundColor,border:style.borderTopWidth,
rail:getComputedStyle(row,'::before').width,
railColor:getComputedStyle(row,'::before').backgroundColor,
gap:row.querySelector('.resource-row-icon').getBoundingClientRect().x-
row.getBoundingClientRect().x-parseFloat(getComputedStyle(row,'::before').width),
selected:row.classList.contains('selected')};
}
""", token);
var actual = sample.GetProperty("actual").EnumerateArray().Select(v => v.GetInt32()).ToArray();
var expected = sample.GetProperty("expected").EnumerateArray().Select(v => v.GetInt32()).ToArray();
Assert.All(actual.Zip(expected), pair => Assert.InRange(Math.Abs(pair.First - pair.Second), 0, 1));
Assert.Equal("0px", sample.GetProperty("border").GetString());
Assert.Equal(sample.GetProperty("selected").GetBoolean() ? "3px" : "2px", sample.GetProperty("rail").GetString());
Assert.Equal(!sample.GetProperty("selected").GetBoolean(),
sample.GetProperty("railColor").GetString() == "rgba(0, 0, 0, 0)");
Assert.True(sample.GetProperty("gap").GetDouble() >= 7);
if (token is null)
{
Assert.Equal("rgba(0, 0, 0, 0)", sample.GetProperty("background").GetString());
}
}
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task DynamicRows_PreserveTokenGeometryAfterModelAndPresentationChanges(bool dark)
{
await RunTestAsync(async page =>
{
var snapshots = new List<JsonElement>();
var stage = "initial-additions";
fixture.StartDynamicSession();
await fixture.SeedActivityAsync();
await page.SetViewportSizeAsync(2000, 1000);
await page.GotoAsync("/resources/api?pane=tags");
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(6);
var additions = Enumerable.Range(0, 22).Select(MetadataResourcesFixture.CreateDynamicChild).ToArray();
await fixture.PublishAsync(additions.Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)).ToArray());
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28);
foreach (var (density, height) in new[] { ("Compact", 44d), ("Comfortable", 52d), ("Spacious", 60d) })
{
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await AssertGeometryAsync(height);
var pane = page.Locator(".resource-pane");
foreach (var width in new[] { 420, 560, 769, 1279, 1280, 1281, 1304, 1439, 1440, 1441, 1599, 1600, 1601, 1799, 1800, 1801, 2000 })
{
stage = $"{density}-{width}-expanded";
if (await page.Locator(".pane-full-canvas").CountAsync() > 0)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
}
await page.SetViewportSizeAsync(width <= 560 ? 1440 : width, 1000);
if (width <= 560)
{
var separator = page.Locator(".resource-pane-resizer");
await separator.FocusAsync();
await separator.PressAsync(width == 420 ? "Home" : "End");
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", width.ToString(CultureInfo.InvariantCulture));
}
else
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
var chromeWidth = await page.EvaluateAsync<double>("() => innerWidth - document.querySelector('.resources-layout').getBoundingClientRect().width");
await page.SetViewportSizeAsync(width + (int)Math.Round(chromeWidth), 1000);
await page.WaitForFunctionAsync("""
width => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - width) <= .5
""", width);
}
if (width >= 1440)
{
await Assertions.Expect(page.Locator(".resource-row-insight-requests")).ToHaveCountAsync(3);
await Assertions.Expect(page.Locator(".resource-row-insight-latency")).ToHaveCountAsync(2);
await Assertions.Expect(page.Locator(".resource-row-insight-failed")).ToHaveCountAsync(1);
await Assertions.Expect(page.Locator(".resource-row-insight-requests").First).ToBeVisibleAsync();
}
else if (width >= 960)
{
await Assertions.Expect(page.Locator(".resource-row-insight-requests").First).Not.ToBeVisibleAsync();
}
await AssertGeometryAsync(height);
await page.ScreenshotAsync(new()
{
Path = Path.Combine(AppContext.BaseDirectory, $"ux29-final-{(dark ? "dark" : "light")}-{density}-pane{width}.png")
});
var parent = page.Locator(".resource-row[data-resource-name='api'] .resource-row-link");
await parent.FocusAsync();
await parent.PressAsync("ArrowLeft");
await Assertions.Expect(page.Locator("[data-resource-name='rebuilder-0']")).ToHaveCountAsync(0);
stage = $"{density}-{width}-collapsed";
await AssertGeometryAsync(height);
await parent.PressAsync("ArrowRight");
await Assertions.Expect(page.Locator("[data-resource-name='rebuilder-0']")).ToBeVisibleAsync();
var filter = pane.GetByRole(AriaRole.Searchbox);
await filter.FillAsync("rebuilder");
await Assertions.Expect(page.Locator("[data-resource-name='worker']")).ToHaveCountAsync(0);
stage = $"{density}-{width}-filtered";
await AssertGeometryAsync(height);
await filter.FillAsync("");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28);
stage = $"{density}-{width}-filter-cleared";
await AssertGeometryAsync(height);
}
}
await fixture.PublishAsync(additions.Take(11).Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Delete, resource)).ToArray());
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(17);
stage = "eleven-removed";
await AssertGeometryAsync(60);
await fixture.PublishAsync(additions.Reverse().Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)).ToArray());
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28);
stage = "reinserted-reverse-order";
await AssertGeometryAsync(60);
// OrderStore survives browser contexts; choose a movable child in the actual order.
var siblings = page.Locator(".resource-row[data-resource-parent='api']");
var expectedOrder = await siblings.EvaluateAllAsync<string[]>("rows => rows.map(row => row.dataset.resourceName)");
Assert.True(expectedOrder.Length >= 2);
var reorder = siblings.Nth(1).Locator(".resource-row-link");
(expectedOrder[0], expectedOrder[1]) = (expectedOrder[1], expectedOrder[0]);
var console = new ConcurrentQueue<string>();
page.Console += (_, message) => console.Enqueue($"{message.Type}: {message.Text}");
await reorder.FocusAsync();
await reorder.PressAsync("Alt+ArrowUp");
try
{
await page.WaitForFunctionAsync("""
expected => JSON.stringify([...document.querySelectorAll('.resource-row[data-resource-parent="api"]')].map(row => row.dataset.resourceName)) === JSON.stringify(expected)
""", expectedOrder);
}
catch (TimeoutException)
{
var prefix = Path.Combine(AppContext.BaseDirectory, $"dynamic-reorder-{(dark ? "dark" : "light")}");
await page.ScreenshotAsync(new() { Path = $"{prefix}.png", FullPage = true });
var state = await page.Locator(".resource-pane").EvaluateAsync<string>("""
(pane, expected) => JSON.stringify({
expected,
rows: [...pane.querySelectorAll('.resource-row[data-resource-name]')].map(row => ({
name: row.dataset.resourceName, parent: row.dataset.resourceParent,
height: row.getBoundingClientRect().height,
shortcuts: row.querySelector('.resource-row-link').getAttribute('aria-keyshortcuts')
})),
background: getComputedStyle(pane).backgroundColor
}, null, 2)
""", expectedOrder);
await File.WriteAllTextAsync($"{prefix}.json", state);
await File.WriteAllLinesAsync($"{prefix}.console.log", console);
throw;
}
stage = "reordered";
await AssertGeometryAsync(60);
var selectAll = page.GetByRole(AriaRole.Button, new() { Name = "Select all", Exact = true });
await selectAll.ClickAsync();
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(28);
stage = "all-nested-selected";
await AssertGeometryAsync(60);
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
stage = "all-nested-selected-sidebar";
await AssertGeometryAsync(60);
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "Tags", Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resource-tag-group").First).ToBeVisibleAsync();
stage = "grouped-tags";
await AssertGeometryAsync(60);
await page.ReloadAsync();
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
stage = "reloaded";
await AssertGeometryAsync(60);
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(6);
stage = "ungrouped-after-reload";
await AssertGeometryAsync(60);
await File.WriteAllTextAsync(
Path.Combine(AppContext.BaseDirectory, $"dynamic-geometry-{(dark ? "dark" : "light")}.json"),
JsonSerializer.Serialize(snapshots, new JsonSerializerOptions { WriteIndented = true }));
async Task AssertGeometryAsync(double height)
{
var result = await page.Locator(".resource-pane-list").EvaluateAsync<JsonElement>("""
(list, {height, stage}) => {
const errors = [], rows = [...list.querySelectorAll('.resource-row[data-resource-name]')];
const nameOffsets = rows.map(row =>
row.querySelector('.resource-row-name').getBoundingClientRect().x -
row.querySelector('.resource-row-icon').getBoundingClientRect().right);
if (Math.max(...nameOffsets) - Math.min(...nameOffsets) > .5)
errors.push('persistent/ordinary name offset variance');
for (const [i,row] of rows.entries()) {
const b = row.getBoundingClientRect(), icon = row.querySelector('.resource-row-icon').getBoundingClientRect();
const rail = getComputedStyle(row,'::before');
if ((rail.backgroundColor === 'rgba(0, 0, 0, 0)') === row.classList.contains('selected') ||
icon.x - b.x - parseFloat(rail.width) < 7)
errors.push(`${row.dataset.resourceName}: selection rail/spacing`);
if (b.height !== height) errors.push(`${row.dataset.resourceName}: height ${b.height}`);
if (icon.width !== 32 || icon.height !== 32) errors.push(`${row.dataset.resourceName}: icon box`);
const mainGlyph = row.querySelector('.resource-row-glyph svg').getBoundingClientRect();
if (mainGlyph.width !== 16 || mainGlyph.height !== 16) errors.push(`${row.dataset.resourceName}: glyph`);
const expectedFont = height === 44 ? 12 : height === 52 ? 14 : 16;
if (parseFloat(getComputedStyle(row.querySelector('.resource-row-name')).fontSize) !== expectedFont)
errors.push(`${row.dataset.resourceName}: density font`);
for (const chip of row.querySelectorAll('.resource-row-insight')) {
const chipBox = chip.getBoundingClientRect();
if (!chipBox.width) continue;
const style = getComputedStyle(chip), glyph = chip.querySelector('svg').getBoundingClientRect();
const font = height === 44 ? 11 : height === 52 ? 12 : 14;
const line = height === 44 ? 16 : height === 52 ? 18 : 20;
const target = height === 44 ? 24 : height === 52 ? 28 : 32;
const iconSize = height === 44 ? 12 : height === 52 ? 14 : 16;
if (parseFloat(style.fontSize) !== font || parseFloat(style.lineHeight) !== line ||
chipBox.height !== target || chipBox.width < target ||
glyph.width !== iconSize || glyph.height !== iconSize ||
style.fontVariantNumeric !== 'tabular-nums')
errors.push(`${row.dataset.resourceName}: chip density geometry; font=${style.fontSize}; line=${style.lineHeight}; target=${chipBox.width}x${chipBox.height}; icon=${glyph.width}x${glyph.height}`);
}
if (i && b.y < rows[i-1].getBoundingClientRect().bottom + 1) errors.push(`${i}: overlap/gap`);
if (i && rows[i-1].nextElementSibling === row &&
b.y - rows[i-1].getBoundingClientRect().bottom !== 1)
errors.push(`${row.dataset.resourceName}: nonuniform sibling gap`);
if (icon.y < b.y || icon.bottom > b.bottom) errors.push(`${i}: icon outside row`);
const guide = row.querySelector('.resource-tree-guide.branch');
if (guide) {
const g = guide.getBoundingClientRect(), elbow = getComputedStyle(guide,'::after');
if (getComputedStyle(guide).borderInlineStartStyle !== 'dotted' || elbow.borderTopStyle !== 'dotted')
errors.push(`${i}: tree stroke`);
if (g.x + parseFloat(elbow.left) + parseFloat(elbow.width) !== icon.x ||
g.y + parseFloat(elbow.top) !== icon.y + icon.height/2 ||
g.x !== icon.x + icon.width/2 - parseFloat(getComputedStyle(row).getPropertyValue('--resource-row-depth-step')))
errors.push(`${i}: connector`);
if (!guide.classList.contains('continuing') && g.bottom !== b.y + height/2)
errors.push(`${i}: last-child termination`);
}
const badge = row.querySelector('.persistent-container-badge');
if (badge) {
const bb = badge.getBoundingClientRect(), style = getComputedStyle(badge);
if (style.position !== 'absolute' || style.pointerEvents !== 'none' ||
bb.right !== icon.right + 4 || bb.bottom !== icon.bottom + 3 ||
bb.y < mainGlyph.bottom || bb.bottom > b.bottom ||
bb.right > row.querySelector('.resource-row-name').getBoundingClientRect().x)
errors.push(`${i}: badge overlay/clipping`);
}
if (Math.abs(mainGlyph.x + mainGlyph.width/2 - icon.x - icon.width/2) > .5 ||
Math.abs(mainGlyph.y + mainGlyph.height/2 - icon.y - icon.height/2) > .5)
errors.push(`${i}: displaced base glyph`);
for (const time of row.querySelectorAll('time')) {
if (time.getBoundingClientRect().width &&
(time.scrollWidth > time.clientWidth || time.tabIndex !== 0 ||
!time.getAttribute('aria-label')?.includes(time.textContent.trim()) ||
!time.getAttribute('data-tooltip')?.includes(time.textContent.trim())))
errors.push(`${row.dataset.resourceName}: incomplete start time; client=${time.clientWidth}; scroll=${time.scrollWidth}; text=${time.textContent}`);
}
}
const fullCanvas = !!list.closest('.pane-full-canvas');
const paneWidth = list.closest('.resource-pane').getBoundingClientRect().width;
const columns = {};
if (fullCanvas && paneWidth >= 960) {
const templates = new Set(rows.map(row => getComputedStyle(row).gridTemplateColumns));
if (templates.size !== 1) errors.push('content-dependent grid tracks');
for (const selector of ['.resource-row-health','.resource-row-endpoint',
'.resource-row-source','.resource-row-insights',
'.resource-row-insight-requests','.resource-row-insight-latency','.resource-row-insight-failed',
'.resource-row-actions']) {
const values = rows.map(row => row.querySelector(selector)?.getBoundingClientRect())
.filter(box => box?.width).map(box => box.x);
columns[selector] = values;
if (values.length && Math.max(...values) - Math.min(...values) > 1)
errors.push(`${selector}: column variance`);
}
for (const row of rows) {
const box = selector => row.querySelector(selector)?.getBoundingClientRect();
const identity = box('.resource-row-link'), health = box('.resource-row-health');
const details = box('.resource-row-endpoints'), insights = box('.resource-row-insights');
const actions = box('.resource-row-actions'), bounds = row.getBoundingClientRect();
if (identity.right > health.x + .5 || health.right > details.x + .5 ||
details.right > (insights?.width ? insights.x : actions.x) + .5 ||
(insights?.width && insights.right > actions.x + .5) ||
actions.right > bounds.right + .5)
errors.push(`${row.dataset.resourceName}: intersecting tracks`);
if (insights?.width) {
const gap = parseFloat(getComputedStyle(row).columnGap);
const latency = box('.resource-row-insight-latency');
if (Math.abs(actions.x - insights.right - gap) > .5)
errors.push(`${row.dataset.resourceName}: activity/action gap; activityRight=${insights.right}; actionX=${actions.x}; expected=${gap}`);
if (latency?.width && Math.abs(latency.right - insights.right) > .5)
errors.push(`${row.dataset.resourceName}: latency not trailing; latencyRight=${latency.right}; trackRight=${insights.right}`);
}
for (const label of row.querySelectorAll('.resource-relationship-label')) {
if (label.getBoundingClientRect().width && label.scrollWidth > label.clientWidth)
errors.push(`${row.dataset.resourceName}: relationship label cutoff; client=${label.clientWidth}; scroll=${label.scrollWidth}`);
}
for (const label of row.querySelectorAll('.resource-row-health-status,.resource-row-start-time time,.resource-relationship-count,.resource-row-insight > span')) {
if (label.getBoundingClientRect().width && label.scrollWidth > label.clientWidth)
errors.push(`${row.dataset.resourceName}: non-truncatable ${label.className}; client=${label.clientWidth}; scroll=${label.scrollWidth}; text=${label.textContent}`);
}
const friendly = row.querySelector('.resource-row-endpoint-name');
const address = row.querySelector('.resource-row-endpoint-address');
if (paneWidth < 1600 && friendly?.getBoundingClientRect().width &&
friendly.scrollWidth > friendly.clientWidth && address?.scrollWidth > address?.clientWidth)
errors.push(`${row.dataset.resourceName}: both endpoint summaries truncated`);
}
}
return {stage, height, errors, columns,
paneWidth: list.getBoundingClientRect().width,
rows: rows.map(row => ({name:row.dataset.resourceName,
parent:row.dataset.resourceParent,
bounds:row.getBoundingClientRect().toJSON(),
icon:row.querySelector('.resource-row-icon').getBoundingClientRect().toJSON(),
badge:row.querySelector('.persistent-container-badge')?.getBoundingClientRect().toJSON()}))};
}
""", new { height, stage });
var errors = result.GetProperty("errors").EnumerateArray().Select(error => error.GetString()).ToArray();
Assert.True(errors.Length == 0, $"{stage}: {string.Join("; ", errors)}");
snapshots.Add(result);
}
});
}
[Theory]
[InlineData(769)]
[InlineData(1440)]
[InlineData(2000)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ContextMenu_PreservesNativeEndpointActivityAndControlMenus(int width)
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
await fixture.SeedActivityAsync();
await page.SetViewportSizeAsync(width, 900);
await page.GotoAsync("/resources/cache?pane=tags");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
var original = page.Url;
var row = page.Locator(".resource-row[data-resource-name='api']").First;
var menu = page.Locator("fluent-menu[aria-labelledby='resource-pane-context-menu-header'] fluent-menu-list");
var targets = new List<ILocator>
{
row.Locator(".resource-row-primary-endpoint svg").First,
row.GetByRole(AriaRole.Button, new() { Name = "Actions for api", Exact = true })
};
if (width >= 1440)
{
// Activity is intentionally undisclosed, and its queries suspended, below 1440px.
await Assertions.Expect(row.Locator(".resource-row-insight-latency")).ToBeVisibleAsync();
targets.Add(row.Locator(".resource-row-insight-requests span").First);
targets.Add(row.Locator(".resource-row-insight-latency span").First);
}
else
{
await Assertions.Expect(row.Locator(".resource-row-insight")).ToHaveCountAsync(0);
}
var selected = page.Locator(".resource-row[data-resource-name] > .resource-row-link[aria-current]");
var selection = await selected.EvaluateAllAsync<string[]>("links => links.map(link => link.parentElement.dataset.resourceName)");
await page.EvaluateAsync("""
() => document.addEventListener('contextmenu', event => {
window.resourceContextMenuEvent = event;
}, true)
""");
foreach (var target in targets)
{
await target.ClickAsync(new() { Button = MouseButton.Right });
Assert.False(await page.EvaluateAsync<bool>("() => window.resourceContextMenuEvent.defaultPrevented"));
await Assertions.Expect(menu).Not.ToBeVisibleAsync();
Assert.Equal(original, page.Url);
Assert.Equal(selection, await selected.EvaluateAllAsync<string[]>("links => links.map(link => link.parentElement.dataset.resourceName)"));
await Assertions.Expect(page.Locator("[data-resource-name='cache'] .resource-row-link").First)
.ToHaveAttributeAsync("aria-current", "page");
await page.Keyboard.PressAsync("Escape");
}
foreach (var target in new[] { row.Locator(".resource-row-icon"), row.Locator(".resource-row-wide-metadata") })
{
await target.ClickAsync(new() { Button = MouseButton.Right });
await Assertions.Expect(menu).ToBeVisibleAsync();
await Assertions.Expect(menu.GetByText("api", new() { Exact = true })).ToBeVisibleAsync();
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(row.Locator(".resource-row-link")).ToBeFocusedAsync();
}
});
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ActivityLinks_OpenExactResourceWindowAndRestoreSelection(bool latency, bool keyboard)
{
await RunTestAsync(async page =>
{
fixture.StartDynamicSession();
var seededTime = await fixture.SeedActivityAsync();
await page.SetViewportSizeAsync(2000, 1000);
await page.GotoAsync("/resources/cache?pane=tags");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
var original = page.Url;
var row = page.Locator(".resource-row[data-resource-name='api']").First;
var link = row.Locator(latency ? ".resource-row-insight-latency" : ".resource-row-insight-requests");
await Assertions.Expect(link).ToBeVisibleAsync();
var href = await link.GetAttributeAsync("href");
Assert.NotNull(href);
var expected = new Uri(new Uri(page.Url), href);
Assert.Equal("/traces/resource/api", expected.AbsolutePath);
var filters = TelemetryFilterFormatter.DeserializeFiltersFromString(QueryHelpers.ParseQuery(expected.Query)["filters"].ToString());
Assert.Equal(2, filters.Count);
Assert.Equal(KnownTraceFields.TimestampField, filters[0].Field);
Assert.Equal(KnownTraceFields.TimestampField, filters[1].Field);
Assert.Equal(FilterCondition.GreaterThanOrEqual, filters[0].Condition);
Assert.Equal(FilterCondition.LessThan, filters[1].Condition);
var start = DateTime.ParseExact(filters[0].Value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
var end = DateTime.ParseExact(filters[1].Value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
Assert.Equal(DateTimeKind.Utc, start.Kind);
Assert.Equal(DateTimeKind.Utc, end.Kind);
Assert.Equal(TimeSpan.FromMinutes(5), end - start);
Assert.InRange(seededTime, start, end);
await page.EvaluateAsync("""
() => {
window.activityRowActivations = 0;
document.addEventListener('click', event => {
if (event.target.closest('.resource-row[data-resource-name="api"] > .resource-row-link'))
window.activityRowActivations++;
});
}
""");
if (keyboard)
{
await link.FocusAsync();
await Assertions.Expect(link).ToBeFocusedAsync();
await link.PressAsync("Enter");
}
else
{
await link.ClickAsync();
}
await Assertions.Expect(page).ToHaveURLAsync(new Regex($"^{Regex.Escape(expected.AbsoluteUri)}$"));
Assert.Equal(expected.AbsoluteUri, page.Url);
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='api'] .resource-row-link").First)
.ToHaveAttributeAsync("aria-current", "page");
await Assertions.Expect(page.Locator(".trace-name-content")).ToHaveCountAsync(20);
await Assertions.Expect(page.Locator(".trace-name-content")
.Filter(new() { HasTextRegex = new Regex(@"api: Test span\. Id: api-1\b") })).ToBeVisibleAsync();
await Assertions.Expect(page.GetByRole(AriaRole.Toolbar, new() { Name = "Page toolbar" })
.GetByRole(AriaRole.Button, new() { Name = "Filters", Exact = true })).ToBeVisibleAsync();
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.activityRowActivations"));
await page.EvaluateAsync("() => history.back()");
await Assertions.Expect(page).ToHaveURLAsync(original);
await Assertions.Expect(page.Locator("h1.resource-title")).ToHaveTextAsync("cache");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='cache'] .resource-row-link").First)
.ToHaveAttributeAsync("aria-current", "page");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8);
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task RowComposition_AlignsStatusAndSingleLineDetailsWithAccessibleContrast(bool dark)
{
await RunTestAsync(async page =>
{
await fixture.SeedActivityAsync();
await page.SetViewportSizeAsync(2000, 1000);
await page.GotoAsync("/resources/api?pane=tags");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await page.WaitForFunctionAsync("""
() => document.querySelector('.resource-pane').getBoundingClientRect().width ===
document.querySelector('.resources-layout').getBoundingClientRect().width
""");
var row = page.Locator(".resource-row[data-resource-name='api']").First;
foreach (var state in new[] { "selected", "hover", "focus" })
{
if (state == "hover")
{
await row.HoverAsync();
}
if (state == "focus")
{
await page.Mouse.MoveAsync(1, 1);
await row.Locator(".resource-row-link").FocusAsync();
}
var geometry = await row.EvaluateAsync<double[]>("""
row => {
const box = selector => row.querySelector(selector).getBoundingClientRect();
const time = box('.resource-row-start-time');
const detail = box('.resource-row-endpoints'), endpoint = box('.resource-row-endpoint-address');
const source = box('.resource-row-source'), rail = box('.resource-row-actions');
const center = b => b.y + b.height / 2;
return [center(time) - center(row.getBoundingClientRect()), center(endpoint) - center(detail),
center(source) - center(detail), center(rail) - center(row.getBoundingClientRect())];
}
""");
Assert.Equal(0, geometry[0]);
foreach (var (selector, delta) in new[]
{
("endpoint/detail center", geometry[1]),
("source/detail center", geometry[2]),
("actions/row center", geometry[3])
})
{
Assert.True(Math.Abs(delta) <= 1, $"{selector}: delta={delta}; state={state}; dark={dark}");
}
var contrast = await row.EvaluateAsync<JsonElement>("""
row => {
const canvas = document.createElement('canvas'), ctx = canvas.getContext('2d', {willReadFrequently:true});
canvas.width = canvas.height = 1;
const luminance = rgb => rgb.slice(0,3).map(v => {
v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
}).reduce((sum,v,i) => sum + v * [.2126,.7152,.0722][i], 0);
return [...row.querySelectorAll('.resource-row-name,.resource-row-sub,.resource-row-start-time,.resource-row-endpoint-address,.resource-row-source-value,.resource-row-insight')].map(e => {
const ancestors = []; for (let a = e; a; a = a.parentElement) ancestors.unshift(a);
ctx.fillStyle = 'white'; ctx.fillRect(0,0,1,1);
for (const a of ancestors) { ctx.fillStyle = getComputedStyle(a).backgroundColor; ctx.fillRect(0,0,1,1); }
const background = luminance(ctx.getImageData(0,0,1,1).data);
ctx.fillStyle = getComputedStyle(e).color; ctx.fillRect(0,0,1,1);
const foreground = luminance(ctx.getImageData(0,0,1,1).data);
return {ratio:(Math.max(background,foreground)+.05)/(Math.min(background,foreground)+.05),
minimum:e.textContent.trim() ? 4.5 : 3};
});
}
""");
Assert.NotEmpty(contrast.EnumerateArray());
Assert.All(contrast.EnumerateArray(), sample => Assert.True(sample.GetProperty("ratio").GetDouble() >= sample.GetProperty("minimum").GetDouble(),
$"{state}; dark={dark}; contrast={sample}"));
}
await Assertions.Expect(row.Locator(".resource-relationship-summary")).ToHaveCountAsync(0);
var selectAll = page.GetByRole(AriaRole.Button, new() { Name = "Select all", Exact = true });
await Assertions.Expect(selectAll).Not.ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
Assert.Null(await selectAll.GetAttributeAsync("aria-description"));
await selectAll.FocusAsync();
await selectAll.PressAsync("Space");
await Assertions.Expect(selectAll).ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(8);
foreach (var density in new[] { "Compact", "Comfortable", "Spacious" })
{
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await Assertions.Expect(selectAll).ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
Assert.All(await page.Locator(".resource-row.selected[data-resource-name]").EvaluateAllAsync<string[]>(
"rows => rows.map(row => getComputedStyle(row).backgroundColor)"),
background => Assert.NotEqual("rgba(0, 0, 0, 0)", background));
}
await row.Locator(".resource-row-link").ClickAsync();
await Assertions.Expect(selectAll).Not.ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
Assert.Null(await selectAll.GetAttributeAsync("aria-description"));
var cache = page.Locator(".resource-row[data-resource-name='cache']").First;
await page.Mouse.MoveAsync(1, 1);
await selectAll.FocusAsync();
Assert.Equal("rgba(0, 0, 0, 0)", await cache.EvaluateAsync<string>("row => getComputedStyle(row).backgroundColor"));
await cache.Locator(".resource-row-link").ClickAsync(new() { Modifiers = [KeyboardModifier.Control] });
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(4);
await selectAll.ClickAsync();
await Assertions.Expect(selectAll).ToHaveAttributeAsync("aria-description", Resources.Layout.ResourcePaneAllSelected);
await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(8);
});
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task HierarchyAndSource_PreserveTreePathsAndCompleteDisclosure(bool dark, bool forcedColors)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 1000);
await page.GotoAsync("/resources/api?pane=tags");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.EmulateMediaAsync(new() { ForcedColors = forcedColors ? ForcedColors.Active : ForcedColors.None });
await page.Locator("#resourcePaneGroupingButton").ClickAsync();
await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }).ClickAsync();
var nested = page.Locator(".resource-row[data-resource-name='frontend']");
await Assertions.Expect(nested.Locator(".resource-row-link")).ToHaveAttributeAsync("data-resource-depth", "2");
Assert.Equal(2, await nested.Locator(".resource-tree-guide").CountAsync());
Assert.Equal(1, await page.Locator(".resource-row[data-resource-name='queue'] .resource-tree-guide.continuing").CountAsync());
Assert.Equal(0, await page.Locator(".resource-row[data-resource-name='worker'] .resource-tree-guide.continuing").CountAsync());
var parent = page.Locator(".resource-row[data-resource-name='cache'] .resource-row-link").First;
await parent.FocusAsync();
await parent.PressAsync("ArrowLeft");
await Assertions.Expect(nested).ToHaveCountAsync(0);
await parent.PressAsync("ArrowRight");
await Assertions.Expect(nested).ToBeVisibleAsync();
await parent.PressAsync("ArrowRight");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='queue'] .resource-row-link")).ToBeFocusedAsync();
await page.Keyboard.PressAsync("ArrowLeft");
await Assertions.Expect(page.Locator(".resource-row[data-resource-name='frontend']")).ToHaveCountAsync(0);
await page.Keyboard.PressAsync("ArrowRight");
await Assertions.Expect(nested).ToBeVisibleAsync();
var pane = page.Locator(".resource-pane");
var theme = forcedColors ? "forced-colors" : dark ? "dark" : "light";
foreach (var width in new[] { 420, 560 })
{
var separator = page.GetByRole(AriaRole.Separator, new() { Name = Resources.Layout.ResourcePaneResize });
var box = await separator.BoundingBoxAsync();
var initial = await pane.BoundingBoxAsync();
Assert.NotNull(box);
Assert.NotNull(initial);
await page.Mouse.MoveAsync(box.X + box.Width / 2, box.Y + box.Height / 2);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(box.X + box.Width / 2 + width - initial.Width, box.Y + box.Height / 2);
await page.Mouse.UpAsync();
await page.WaitForFunctionAsync("width => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - width) < 1", width);
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, $"resource-tree-{theme}-{width}.png") });
Assert.False(await pane.EvaluateAsync<bool>("el => el.querySelector('.resource-pane-list').scrollWidth > el.querySelector('.resource-pane-list').clientWidth"));
}
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
try
{
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
}
catch (PlaywrightException ex)
{
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, $"canvas-failure-{theme}.png") });
throw new InvalidOperationException(await page.Locator(".resources-layout").EvaluateAsync<string>("""
e => {
const chain=[];
for(let p=e;p;p=p.parentElement){const s=getComputedStyle(p);chain.push({tag:p.tagName,cls:p.className,
box:p.getBoundingClientRect().toJSON(),display:s.display,visibility:s.visibility,height:s.height});}
return JSON.stringify(chain);
}
"""), ex);
}
foreach (var width in new[] { 1304, 1440, 2000 })
{
await page.SetViewportSizeAsync(width, 1000);
await page.WaitForFunctionAsync("""
() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width -
document.querySelector('.resources-layout').getBoundingClientRect().width) < 1
""");
var source = page.Locator(".resource-row[data-resource-name='api'] .resource-row-source-value");
var before = page.Url;
await Assertions.Expect(source).ToHaveTextAsync("MyFrontend.csproj");
await source.FocusAsync();
await Assertions.Expect(source).ToBeFocusedAsync();
await Assertions.Expect(source).ToHaveAttributeAsync("data-tooltip", Path.Combine("workspace", "MyFrontend.csproj"));
await Assertions.Expect(source).ToHaveAttributeAsync("aria-label", $"Source: {Path.Combine("workspace", "MyFrontend.csproj")}");
await Assertions.Expect(page.Locator(".resource-relationship-summary, .resource-row-dependencies, .resource-row-references")).ToHaveCountAsync(0);
var columns = await page.Locator(".resource-row-source").EvaluateAllAsync<double[]>(
"sources => sources.map(source => source.getBoundingClientRect().x)");
Assert.InRange(columns.Max() - columns.Min(), 0, 1);
var image = page.Locator(".resource-row[data-resource-name='cache'] .resource-row-source-value");
await Assertions.Expect(image).ToHaveAttributeAsync("data-tooltip", MetadataResourcesFixture.LongImage);
Assert.True(await image.EvaluateAsync<bool>("source => source.scrollWidth > source.clientWidth"));
Assert.Equal(before, page.Url);
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, $"resource-tree-{theme}-canvas-{width}.png") });
}
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ExtremeName_RetainsFullAccessibleAndHoverKeyboardDisclosure()
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.GotoAsync("/resources/cache?pane=tags");
var link = page.Locator(".resource-row[data-resource-name='worker'] .resource-row-link").First;
await Assertions.Expect(link).ToHaveAttributeAsync("aria-label", MetadataResourcesFixture.LongResourceName);
Assert.True(await link.Locator(".resource-row-name").EvaluateAsync<bool>("el => el.scrollWidth > el.clientWidth"));
await link.Locator(".resource-row-name").HoverAsync();
await Assertions.Expect(link).ToHaveAttributeAsync("data-tooltip", MetadataResourcesFixture.LongResourceName);
await Assertions.Expect(page.Locator("aspire-tooltip-provider fluent-tooltip[aria-hidden='false']")).ToHaveTextAsync(MetadataResourcesFixture.LongResourceName);
await page.Mouse.MoveAsync(1, 1);
await page.Keyboard.PressAsync("Tab");
await link.FocusAsync();
await Assertions.Expect(page.Locator("aspire-tooltip-provider fluent-tooltip[aria-hidden='false']")).ToHaveTextAsync(MetadataResourcesFixture.LongResourceName);
await link.PressAsync("Shift+F10");
await Assertions.Expect(page.Locator("#resource-pane-context-menu-header")).ToHaveTextAsync(MetadataResourcesFixture.LongResourceName);
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(link).ToBeFocusedAsync();
});
}
[Theory]
[InlineData(390, false)]
[InlineData(420, false)]
[InlineData(560, false)]
[InlineData(420, true)]
[InlineData(560, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task Rows_KeepDensityRhythmAndProtectedSidebarActions(int width, bool dark)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.GotoAsync("/resources/cache?pane=tags");
foreach (var (density, height) in new[] { ("Compact", 44d), ("Comfortable", 52d), ("Spacious", 60d) })
{
await page.SetViewportSizeAsync(1440, 900);
if (await page.Locator(".resources-layout.pane-full-canvas").CountAsync() > 0)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToHaveCountAsync(0);
}
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
var pane = page.Locator(".resource-pane");
if (width == 390)
{
await page.SetViewportSizeAsync(390, 844);
await Assertions.Expect(page.Locator(".resource-pane-resizer")).ToHaveCountAsync(0);
if (!await pane.IsVisibleAsync())
{
await page.Locator(".resource-picker").ClickAsync();
}
}
else
{
var separator = page.GetByRole(AriaRole.Separator, new() { Name = Resources.Layout.ResourcePaneResize });
var handle = await separator.BoundingBoxAsync();
var initial = await pane.BoundingBoxAsync();
Assert.NotNull(handle);
Assert.NotNull(initial);
await page.Mouse.MoveAsync(handle.X + handle.Width / 2, handle.Y + handle.Height / 2);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(handle.X + handle.Width / 2 + width - initial.Width, handle.Y + handle.Height / 2);
await page.Mouse.UpAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", width.ToString(CultureInfo.InvariantCulture));
}
foreach (var fullCanvas in width == 390 ? new[] { false } : new[] { false, true })
{
if (fullCanvas)
{
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".pane-full-canvas")).ToBeVisibleAsync();
await page.WaitForFunctionAsync("""
() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width -
document.querySelector('.resources-layout').getBoundingClientRect().width) < 1
""");
}
await Assertions.Expect(pane).ToBeVisibleAsync();
var rows = await pane.EvaluateAsync<double[][]>("""
pane => [...pane.querySelectorAll('.resource-row[data-resource-name]')].map(row => {
const rect = row.getBoundingClientRect();
const next = row.nextElementSibling;
const rail = row.querySelector('.resource-row-actions');
const shown = el => el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0;
const overflow = rail.querySelector('.aspire-menu-button:not(:has(.resource-row-more-endpoints))');
const essential = [...rail.querySelectorAll('.resource-row-highlighted, .resource-row-primary-endpoint')];
return [rect.height,
next?.matches('.resource-row[data-resource-name]') ? next.getBoundingClientRect().top - rect.bottom : 1,
rail.getBoundingClientRect().width, shown(overflow) ? 1 : 0,
essential.every(shown) ? 1 : 0];
})
""");
Assert.NotEmpty(rows);
Assert.All(rows, values =>
{
Assert.Equal(height, values[0]);
Assert.Equal(1, values[1]);
Assert.Equal(1, values[3]);
if (!fullCanvas)
{
Assert.Equal(112, values[2]);
Assert.Equal(1, values[4]);
}
});
var list = pane.Locator(".resource-pane-list");
Assert.True(await list.EvaluateAsync<bool>("el => el.scrollWidth <= el.clientWidth + 1"),
$"{density}; full canvas={fullCanvas}; " + await list.EvaluateAsync<string>("""
el => JSON.stringify({
clientWidth: el.clientWidth, scrollWidth: el.scrollWidth,
overflowing: [...el.querySelectorAll('*')].filter(child =>
child.getBoundingClientRect().right > el.getBoundingClientRect().right + 1)
.map(child => ({ className: child.className, width: child.getBoundingClientRect().width }))
})
"""));
}
}
});
}
[Theory]
[InlineData(769, true)]
[InlineData(1440, false)]
[InlineData(2000, false)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task FullCanvas_RowWhitespaceNavigatesOnceAndPreservesNestedActions(int width, bool reducedMotion)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(width, 900);
await page.EmulateMediaAsync(new() { ReducedMotion = reducedMotion ? ReducedMotion.Reduce : ReducedMotion.NoPreference });
await page.GotoAsync("/resources/cache?pane=tags");
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await page.WaitForFunctionAsync("""
() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width -
document.querySelector('.resources-layout').getBoundingClientRect().width) < 1
""");
var row = page.Locator(".resource-row[data-resource-name='api']").First;
var original = page.Url;
var box = await row.BoundingBoxAsync();
Assert.NotNull(box);
await row.ClickAsync(new() { Button = MouseButton.Right, Position = new() { X = box.Width - 4, Y = box.Height / 2 } });
await Assertions.Expect(page.Locator("#resource-pane-context-menu-header")).ToBeVisibleAsync();
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(page.Locator("#resource-pane-context-menu-header")).ToBeHiddenAsync();
Assert.Equal(original, page.Url);
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToBeVisibleAsync();
await row.Locator(".resource-row-actions .aspire-menu-button").Last.ClickAsync();
var menuItem = page.GetByRole(AriaRole.Menuitem, new() { Name = "inspect", Exact = true });
await Assertions.Expect(menuItem).ToBeVisibleAsync();
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(menuItem).ToBeHiddenAsync();
Assert.Equal(original, page.Url);
await page.EvaluateAsync("""
() => {
window.rowLinkActivations = 0;
document.addEventListener('click', event => {
if (event.target.closest('.resource-row[data-resource-name="api"] > .resource-row-link')) {
window.rowLinkActivations++;
}
});
}
""");
var target = await row.Locator(".resource-row-link").GetAttributeAsync("href");
Assert.NotNull(target);
box = await row.BoundingBoxAsync();
Assert.NotNull(box);
await row.ClickAsync(new() { Position = new() { X = box.Width - 4, Y = box.Height / 2 } });
await Assertions.Expect(page).ToHaveURLAsync(new Regex($"{Regex.Escape(target)}$"));
await Assertions.Expect(row.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-current", "page");
await Assertions.Expect(page.Locator("h1.resource-title")).ToHaveTextAsync("api");
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToHaveCountAsync(0);
Assert.Equal(1, await page.EvaluateAsync<int>("() => window.rowLinkActivations"));
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
await page.Locator(".resource-pane-canvas-toggle").ClickAsync();
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToBeVisibleAsync();
await page.WaitForFunctionAsync("""
() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width -
document.querySelector('.resources-layout').getBoundingClientRect().width) < 1
""");
var next = page.Locator(".resource-row[data-resource-name='frontend'] .resource-row-link");
var nextTarget = await next.GetAttributeAsync("href");
Assert.NotNull(nextTarget);
await next.FocusAsync();
await Assertions.Expect(next).ToBeFocusedAsync();
await next.PressAsync("Space");
await Assertions.Expect(page).ToHaveURLAsync(new Regex($"{Regex.Escape(nextTarget)}$"));
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToHaveCountAsync(0);
});
}
[Theory]
[InlineData(420)]
[InlineData(540)]
[InlineData(560)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task Sidebar_PrioritizesNameAndOneIndependentPrimaryEndpoint(int paneWidth)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Light });
await page.GotoAsync("/resources/cache?pane=tags");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Compact", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
var pane = page.Locator(".resource-pane");
var separator = page.GetByRole(AriaRole.Separator, new() { Name = Resources.Layout.ResourcePaneResize });
var handle = await separator.BoundingBoxAsync();
var initialPane = await pane.BoundingBoxAsync();
Assert.NotNull(handle);
Assert.NotNull(initialPane);
await page.Mouse.MoveAsync(handle.X + handle.Width / 2, handle.Y + handle.Height / 2);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(handle.X + handle.Width / 2 + paneWidth - initialPane.Width, handle.Y + handle.Height / 2);
await page.Mouse.UpAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", paneWidth.ToString(CultureInfo.InvariantCulture));
var row = pane.Locator("[data-resource-name='api']").First;
await Assertions.Expect(row.Locator(".resource-row-type")).ToHaveCountAsync(0);
await Assertions.Expect(row.Locator(".resource-row-endpoint-address")).Not.ToBeVisibleAsync();
await Assertions.Expect(row.Locator(".resource-row-link .state-dot + span")).ToHaveTextAsync("Running (Unhealthy)");
await Assertions.Expect(row.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-description", new System.Text.RegularExpressions.Regex("Unhealthy"));
await Assertions.Expect(row.Locator(".resource-row-link .state-dot")).ToHaveCountAsync(1);
Assert.True(await row.Locator(".resource-row-text").EvaluateAsync<double>("el => el.clientWidth") >= 160);
await row.HoverAsync();
await row.Locator(".resource-row-link").FocusAsync();
Assert.True(await row.Locator(".resource-row-text").EvaluateAsync<double>("el => el.clientWidth") >= 160);
var endpoint = row.Locator(".resource-row-primary-endpoint");
await Assertions.Expect(endpoint).ToBeVisibleAsync();
await Assertions.Expect(endpoint).ToHaveAttributeAsync("href", MetadataResourcesFixture.Endpoint);
await Assertions.Expect(endpoint).ToHaveAttributeAsync("target", "_blank");
await Assertions.Expect(endpoint).ToHaveAttributeAsync("rel", "noreferrer noopener");
Assert.Equal(1, await row.Locator(".resource-row-actions a:visible").CountAsync());
await Assertions.Expect(row.Locator(".resource-row-more-endpoints")).Not.ToBeVisibleAsync();
await Assertions.Expect(pane.Locator("[data-resource-name='service-key'] .resource-row-primary-endpoint")).ToHaveCountAsync(0);
var before = page.Url;
await page.Context.RouteAsync("https://example.test/**", route => route.FulfillAsync(new() { Body = "Local endpoint navigation proof." }));
await endpoint.FocusAsync();
var popup = await page.RunAndWaitForPopupAsync(() => page.Keyboard.PressAsync("Enter"));
await Assertions.Expect(popup).ToHaveURLAsync(MetadataResourcesFixture.Endpoint);
await popup.CloseAsync();
Assert.Equal(before, page.Url);
Assert.True(await pane.Locator(".resource-pane-list").EvaluateAsync<bool>("el => el.scrollWidth <= el.clientWidth + 1"));
Assert.True(await page.EvaluateAsync<bool>("() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1"));
});
}
[Theory]
[InlineData(769, false)]
[InlineData(1440, false)]
[InlineData(769, true)]
[InlineData(1440, true)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task CompactRow_RetainsPersistentTypeDuringHoverFocusAndMenu(int width, bool dark)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ColorScheme = dark ? ColorScheme.Dark : ColorScheme.Light });
await page.GotoAsync("/resources/cache?pane=tags");
await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Compact", Exact = true }).ClickAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync();
await page.SetViewportSizeAsync(width, 900);
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
await page.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneFullCanvas, Exact = true }).ClickAsync();
var pane = page.Locator(".resource-pane");
foreach (var name in new[] { "cache", "frontend", "service-key" })
{
var row = pane.Locator($"[data-resource-name='{name}']").First;
var type = row.Locator(".resource-row-type");
var link = row.Locator(".resource-row-link");
await row.ScrollIntoViewIfNeededAsync();
await page.Mouse.MoveAsync(1, 1);
await Assertions.Expect(type).ToBeVisibleAsync();
var typeText = await type.TextContentAsync();
Assert.NotNull(typeText);
var nameBox = await row.Locator(".resource-row-name").BoundingBoxAsync();
Assert.NotNull(nameBox);
await row.HoverAsync();
await Assertions.Expect(type).ToBeVisibleAsync();
await Assertions.Expect(type).ToHaveTextAsync(typeText);
var hoveredNameBox = await row.Locator(".resource-row-name").BoundingBoxAsync();
Assert.NotNull(hoveredNameBox);
Assert.Equal(nameBox.Width, hoveredNameBox.Width);
await link.FocusAsync();
await page.Mouse.MoveAsync(1, 1);
await Assertions.Expect(type).ToBeVisibleAsync();
Assert.Equal("pointer", await link.EvaluateAsync<string>("el => getComputedStyle(el).cursor"));
await row.Locator(".resource-row-actions .aspire-menu-button").Last.ClickAsync();
await Assertions.Expect(type).ToBeVisibleAsync();
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(type).ToBeVisibleAsync();
}
await Assertions.Expect(pane.Locator("[data-resource-name='cache'] .resource-row-type").First)
.ToHaveTextAsync("Container (persistent)");
Assert.True(await pane.EvaluateAsync<bool>("el => el.scrollWidth <= el.clientWidth + 1"));
});
}
[Theory]
[InlineData(769, 1024)]
[InlineData(1440, 900)]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task FullCanvas_BoundsLongFriendlyNameAndPreservesAddress(int width, int height)
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(width, height);
await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Dark });
await page.GotoAsync("/resources?pane=tags");
if (width <= 768)
{
await page.Locator(".resource-picker").ClickAsync();
}
await page.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneFullCanvas, Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToBeVisibleAsync();
var pane = page.Locator(".resource-pane");
var row = pane.Locator("[data-resource-name='frontend']");
await Assertions.Expect(row.Locator(".resource-row-endpoint-name")).ToHaveTextAsync(MetadataResourcesFixture.LongFriendlyName);
await Assertions.Expect(row.Locator(".resource-row-primary-endpoint")).ToHaveAttributeAsync("href", "https://example.test/frontend");
await Assertions.Expect(row.Locator(".resource-row-primary-endpoint")).ToHaveAttributeAsync("aria-label",
$"{MetadataResourcesFixture.LongFriendlyName}: https://example.test/frontend");
if (width >= 420)
{
await AssertBoundedEndpointAsync(pane);
Assert.True(await pane.EvaluateAsync<bool>("el => el.scrollWidth <= el.clientWidth + 1"));
var nameBox = await row.Locator(".resource-row-endpoint-name").BoundingBoxAsync();
var paneBox = await pane.BoundingBoxAsync();
Assert.NotNull(paneBox);
if (nameBox is not null)
{
Assert.True(nameBox.X + nameBox.Width <= paneBox.X + paneBox.Width);
}
}
else
{
await Assertions.Expect(row.Locator(".resource-row-endpoint-address")).Not.ToBeVisibleAsync();
await Assertions.Expect(row.Locator(".resource-row-link")).ToBeVisibleAsync();
}
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, $"resource-pane-label-canvas-{width}.png") });
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task PaneWidth_DisclosesMetadataWithoutChangingSelectionAndKeepsEndpointBehavior()
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 900);
await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Dark });
await page.GotoAsync("/resources?pane=tags");
var pane = page.Locator(".resource-pane");
var api = pane.Locator("[data-resource-name='api']").First;
var metadata = api.Locator(".resource-row-wide-metadata");
await Assertions.Expect(api).ToBeVisibleAsync();
var layoutActions = page.GetByRole(AriaRole.Group, new() { Name = "Layout", Exact = true });
await Assertions.Expect(layoutActions).ToBeVisibleAsync();
var actionNames = await layoutActions.GetByRole(AriaRole.Button).AllTextContentsAsync();
await Assertions.Expect(metadata.Locator(".resource-row-health")).Not.ToBeVisibleAsync();
var url = page.Url;
var rowOrder = await pane.Locator("[data-resource-name]").EvaluateAllAsync<string[]>("rows => rows.map(row => row.dataset.resourceName)");
var separator = page.GetByRole(AriaRole.Separator, new() { Name = Resources.Layout.ResourcePaneResize });
await DragPaneToAsync(460);
await Assertions.Expect(metadata).ToHaveCountAsync(0);
await Assertions.Expect(api.Locator(".resource-row-link .state-dot + span")).ToHaveTextAsync("Running (Unhealthy)");
await Assertions.Expect(api.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-description", new System.Text.RegularExpressions.Regex("Unhealthy"));
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, "resource-pane-label-resize-460.png") });
await DragPaneToAsync(560);
await Assertions.Expect(api.Locator("time")).ToBeVisibleAsync();
await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, "resource-pane-label-resize-560.png") });
await Assertions.Expect(api.Locator("time")).ToHaveAttributeAsync("datetime", MetadataResourcesFixture.StartTime.ToString("O", CultureInfo.InvariantCulture));
await Assertions.Expect(pane.Locator("[data-resource-name='cache'] time")).ToHaveCountAsync(0);
await Assertions.Expect(pane.Locator("[data-resource-name='cache'] .resource-row-endpoint-address")).ToHaveCountAsync(0);
Assert.Equal(url, page.Url);
Assert.Equal(rowOrder, await pane.Locator("[data-resource-name]").EvaluateAllAsync<string[]>("rows => rows.map(row => row.dataset.resourceName)"));
await page.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneFullCanvas, Exact = true }).ClickAsync();
await Assertions.Expect(metadata.Locator(".resource-row-health-status")).ToHaveCountAsync(0);
await Assertions.Expect(metadata.Locator("time")).ToBeVisibleAsync();
await AssertBoundedEndpointAsync(pane);
var endpoint = api.Locator("span.resource-row-endpoint-address");
var masked = DashboardUIHelpers.MaskQueryStringValues(MetadataResourcesFixture.Endpoint);
await Assertions.Expect(endpoint).ToHaveTextAsync(masked);
await Assertions.Expect(endpoint).ToHaveAttributeAsync("data-tooltip", masked);
await endpoint.ScrollIntoViewIfNeededAsync();
await endpoint.HoverAsync();
var tooltip = page.Locator("aspire-tooltip-provider fluent-tooltip");
await Assertions.Expect(tooltip).ToBeVisibleAsync();
await Assertions.Expect(tooltip).ToHaveTextAsync(masked);
await page.Keyboard.PressAsync("Escape");
await page.Keyboard.PressAsync("Tab");
await endpoint.FocusAsync();
await Assertions.Expect(tooltip).ToBeVisibleAsync();
await Assertions.Expect(tooltip).ToHaveTextAsync(masked);
await page.Keyboard.PressAsync("Escape");
var openEndpoint = api.Locator(".resource-row-primary-endpoint");
await Assertions.Expect(openEndpoint).ToHaveAttributeAsync("href", MetadataResourcesFixture.Endpoint);
await Assertions.Expect(openEndpoint).ToHaveAttributeAsync("target", "_blank");
await Assertions.Expect(openEndpoint).ToHaveAttributeAsync("rel", "noreferrer noopener");
await page.Context.RouteAsync("https://example.test/**", route => route.FulfillAsync(new() { Body = "Synthetic endpoint; no external request." }));
var popup = await page.RunAndWaitForPopupAsync(() => openEndpoint.ClickAsync());
try
{
await Assertions.Expect(popup).ToHaveURLAsync(MetadataResourcesFixture.Endpoint);
Assert.Equal(url, page.Url);
Assert.Equal(8, await pane.Locator(".resource-row.selected[data-resource-name]").CountAsync());
}
finally
{
await popup.CloseAsync();
}
var tcp = pane.Locator("[data-resource-name='queue'] span.resource-row-endpoint-address");
await Assertions.Expect(tcp).ToHaveTextAsync(DashboardUIHelpers.MaskQueryStringValues(MetadataResourcesFixture.TcpEndpoint));
await Assertions.Expect(tcp).ToHaveAttributeAsync("tabindex", "0");
await page.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneExitFullCanvas, Exact = true }).ClickAsync();
await page.WaitForFunctionAsync("() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - 560) < 0.05");
var target = api.Locator(".resource-row-link");
var targetBox = await target.BoundingBoxAsync();
Assert.NotNull(targetBox);
await pane.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneFullCanvas, Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToBeVisibleAsync();
await Assertions.Expect(layoutActions).Not.ToBeVisibleAsync();
await AssertSelectedGapsAsync();
await pane.GetByRole(AriaRole.Button, new() { Name = Resources.Layout.ResourcePaneExitFullCanvas, Exact = true }).ClickAsync();
await Assertions.Expect(page.Locator(".resources-layout.pane-full-canvas")).ToHaveCountAsync(0);
await page.WaitForFunctionAsync("() => Math.abs(document.querySelector('.resource-pane').getBoundingClientRect().width - 560) < 0.05");
await Assertions.Expect(layoutActions).ToBeVisibleAsync();
Assert.Equal(actionNames, await layoutActions.GetByRole(AriaRole.Button).AllTextContentsAsync());
var restoredTargetBox = await target.BoundingBoxAsync();
Assert.NotNull(restoredTargetBox);
Assert.Equal(targetBox.Height, restoredTargetBox.Height);
Assert.Equal(targetBox.Width, restoredTargetBox.Width);
await pane.GetByRole(AriaRole.Group, new() { Name = "Backend", Exact = true }).Locator(".resource-tag-link").ClickAsync();
await Assertions.Expect(pane.Locator("[data-resource-name='frontend'].selected")).ToHaveCountAsync(0);
await Assertions.Expect(pane.Locator("[data-resource-name='api'].selected")).ToHaveCountAsync(2);
await Assertions.Expect(pane.Locator("[data-resource-name='cache'].selected")).ToHaveCountAsync(2);
await AssertSelectedGapsAsync();
var selectedTargetBox = await target.BoundingBoxAsync();
Assert.NotNull(selectedTargetBox);
Assert.Equal(targetBox.Height, selectedTargetBox.Height);
Assert.Equal(targetBox.Width, selectedTargetBox.Width);
await separator.PressAsync("Home");
await Assertions.Expect(metadata.Locator(".resource-row-health")).Not.ToBeVisibleAsync();
await Assertions.Expect(target).ToBeVisibleAsync();
async Task DragPaneToAsync(int width)
{
var handle = await separator.BoundingBoxAsync();
var paneBox = await pane.BoundingBoxAsync();
Assert.NotNull(handle);
Assert.NotNull(paneBox);
await page.Mouse.MoveAsync(handle.X + handle.Width / 2, handle.Y + handle.Height / 2);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(handle.X + handle.Width / 2 + width - paneBox.Width, handle.Y + handle.Height / 2, new() { Steps = 5 });
await page.Mouse.UpAsync();
await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", width.ToString(CultureInfo.InvariantCulture));
}
async Task AssertSelectedGapsAsync()
{
var gaps = await pane.Locator(".resource-row.selected").EvaluateAllAsync<double[]>(
"rows => rows.filter(row => row.nextElementSibling?.matches('.resource-row.selected')).map(row => row.nextElementSibling.getBoundingClientRect().top - row.getBoundingClientRect().bottom)");
Assert.NotEmpty(gaps);
Assert.All(gaps, gap => Assert.InRange(gap, 0.95, 1.05));
}
});
}
private static async Task AssertBoundedEndpointAsync(ILocator pane)
{
var row = pane.Locator("[data-resource-name='frontend']");
var friendlyName = row.Locator(".resource-row-endpoint-name");
await Assertions.Expect(friendlyName).ToHaveTextAsync(MetadataResourcesFixture.LongFriendlyName);
var disclosure = await row.Locator(".resource-row-endpoints").EvaluateAsync<double[]>("""
endpoints => {
const name=endpoints.querySelector('.resource-row-endpoint-name');
return [endpoints.getBoundingClientRect().width,name.getBoundingClientRect().width,name.scrollWidth,name.clientWidth];
}
""");
Assert.Equal(disclosure[0] >= 480, disclosure[1] > 0);
if (disclosure[1] > 0)
{
Assert.True(disclosure[2] > disclosure[3]);
}
await Assertions.Expect(row.Locator(".resource-row-endpoint-address")).ToBeVisibleAsync();
var rowBox = await row.BoundingBoxAsync();
var normalLabelRowBox = await pane.Locator("[data-resource-name='api']").First.BoundingBoxAsync();
Assert.NotNull(rowBox);
Assert.NotNull(normalLabelRowBox);
Assert.Equal(normalLabelRowBox.Height, rowBox.Height);
}
public sealed class MetadataResourcesFixture : DashboardServerFixture
{
private ConcurrentBag<Channel<IReadOnlyList<ResourceViewModelChange>>> _updates = [];
public void StartDynamicSession()
{
Volatile.Write(ref _updates, []);
}
public Task PublishAsync(IReadOnlyList<ResourceViewModelChange> changes) =>
Task.WhenAll(_updates.Select(channel => channel.Writer.WriteAsync(changes).AsTask()));
public Task<DateTime> SeedActivityAsync() => SeedActivityAsync([("api", 20), ("frontend", 20), ("queue", 2)]);
public async Task<DateTime> SeedActivityAsync(IReadOnlyList<(string Name, int Count)> resources)
{
var now = DateTime.UtcNow.AddSeconds(-5);
var writer = DashboardApp.Services.GetRequiredService<ITelemetryRepositoryWriter>();
await writer.ClearTracesAsync();
await writer.AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
{
resources.Select(item =>
{
var resource = CreateResource(item.Name);
resource.Attributes.RemoveAt(1);
return new ResourceSpans
{
Resource = resource,
ScopeSpans =
{
new ScopeSpans
{
Scope = CreateScope(),
Spans =
{
Enumerable.Range(1, item.Count).Select(i =>
CreateSpan($"{item.Name}-{i}", $"{item.Name}-{i}", now, now.AddMilliseconds(i),
kind: Span.Types.SpanKind.Server,
status: new Status { Code = item.Name == "api" && i == 1
? Status.Types.StatusCode.Error : Status.Types.StatusCode.Ok }))
}
}
}
};
})
});
return now;
}
protected override void ConfigureServices(IServiceCollection services)
{
var client = new MockDashboardClient(CreateResources(), () =>
{
// Layout and detail subscribers each need the whole update, not competing reads.
var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
_updates.Add(channel);
return channel;
});
services.AddSingleton<IDashboardClient>(client);
services.AddSingleton<IRepositoryFactory>(services => new MockRepositoryFactory(services, client));
}
public static ResourceViewModel CreateDynamicChild(int index)
{
return ModelTestHelpers.CreateResource($"rebuilder-{index}", resourceType: KnownResourceTypes.Executable,
state: KnownResourceState.NotStarted, properties: new()
{
[KnownProperties.Resource.ParentName] =
new(KnownProperties.Resource.ParentName, Value.ForString(index % 2 == 0 ? "api" : "cache"), false, null, 0, null, false)
});
}
public static ResourceViewModel CreatePerformanceResource(int index) =>
Create($"perf-{index}", KnownResourceTypes.Project, KnownResourceState.Running, HealthStatus.Healthy,
StartTime, [], ["Backend", "Shared"]);
public const string Endpoint = "https://example.test/api/a-deliberately-long-endpoint-address-for-the-resource-pane?token=synthetic-secret&mode=preview";
public const string TcpEndpoint = "tcp://example.test:5432?password=synthetic-secret";
public static readonly string LongFriendlyName = "SyntheticFrontend" + new string('A', 150);
public static readonly string LongImage = "registry.example.test/testshop/" + new string('i', 120) + ":latest";
public static readonly string LongResourceName = "worker-" + new string('x', 160);
public static readonly DateTime StartTime = new(2026, 11, 2, 18, 20, 30, DateTimeKind.Utc);
public static IReadOnlyList<ResourceViewModel> CreateResources() =>
[
Create("api", KnownResourceTypes.Project, KnownResourceState.Running, HealthStatus.Unhealthy, StartTime,
[new("https", new Uri(Endpoint), false, false, new("Friendly API", 0))], ["Backend", "Public"]),
Create("frontend", KnownResourceTypes.Project, KnownResourceState.Running, HealthStatus.Healthy, StartTime,
[new("https", new Uri("https://example.test/frontend"), false, false, new(LongFriendlyName, 0))], ["Public"]),
Create("cache", KnownResourceTypes.Container, KnownResourceState.Running, HealthStatus.Healthy, null, [], ["Backend", "Infrastructure"]),
Create("queue", KnownResourceTypes.Container, KnownResourceState.Running, HealthStatus.Degraded, null,
[new("tcp", new Uri(TcpEndpoint), false, false, UrlDisplayPropertiesViewModel.Empty)], ["Infrastructure"]),
Create("worker", KnownResourceTypes.Executable, KnownResourceState.Exited, null, StartTime, [], ["Backend"]),
Create("service-key", KnownResourceTypes.Parameter, KnownResourceState.Running, null, null, [], ["Config"])
];
private static ResourceViewModel Create(string name, string type, KnownResourceState state, HealthStatus? health,
DateTime? startTime, ImmutableArray<UrlViewModel> urls, string[] tags)
{
var tagKey = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag;
var properties = ImmutableDictionary<string, ResourcePropertyViewModel>.Empty.Add(tagKey,
new(tagKey, Value.ForList([.. tags.Select(Value.ForString)]), false, null, 0, null, false));
if (type != KnownResourceTypes.Parameter)
{
var key = type == KnownResourceTypes.Project ? KnownProperties.Project.Path :
type == KnownResourceTypes.Container ? KnownProperties.Container.Image : KnownProperties.Executable.Path;
var value = type == KnownResourceTypes.Project ? Path.Combine("workspace", name == "api" ? "MyFrontend.csproj" : $"{name}.csproj") :
type == KnownResourceTypes.Container ? name == "cache" ? LongImage : "rabbitmq:4" : Path.Combine("tools", name);
properties = properties.Add(key, new(key, Value.ForString(value), false, null, 0, null, false));
}
if (name == "cache")
{
properties = properties.Add(KnownProperties.Container.Lifetime,
new(KnownProperties.Container.Lifetime, Value.ForString("Persistent"), false, null, 0, null, false));
}
if (name is "queue" or "worker" or "frontend")
{
properties = properties.Add(KnownProperties.Resource.ParentName,
new(KnownProperties.Resource.ParentName, Value.ForString(name == "frontend" ? "queue" : "cache"), false, null, 0, null, false));
}
return new ResourceViewModel
{
Name = name,
DisplayName = name == "worker" ? LongResourceName : name,
ResourceType = type,
Uid = $"synthetic-{name}",
ReplicaIndex = 0,
State = state.ToString(),
KnownState = state,
StateStyle = null,
CreationTimeStamp = null,
StartTimeStamp = startTime,
StopTimeStamp = null,
Environment = [],
Urls = urls,
Volumes = [],
Relationships = name switch
{
"api" => [new("cache", KnownRelationshipTypes.WaitFor), new("queue", KnownRelationshipTypes.WaitFor),
new(LongResourceName, KnownRelationshipTypes.WaitFor), new("queue", KnownRelationshipTypes.Reference)],
"queue" => [new("cache", KnownRelationshipTypes.WaitFor)],
"frontend" => [new("queue", KnownRelationshipTypes.Reference)],
_ => []
},
Properties = properties,
Commands = name == "api" ?
[Command("inspect"), Command("diagnose")] :
name == "service-key" ? [Command(CommandViewModel.SetParameterCommand)] : [],
HealthReports = health is { } status ? [new("Synthetic fixture check", status, null, null)] : []
};
}
private static CommandViewModel Command(string name) =>
new(name, CommandViewModelState.Enabled, name, "", confirmationMessage: "", argumentInputs: [],
isHighlighted: true, iconName: "Search", iconVariant: Microsoft.FluentUI.AspNetCore.Components.IconVariant.Regular);
}
}