// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.TestUtilities;
using Aspire.Tests.Shared.DashboardModel;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Playwright;
using Xunit;
namespace Aspire.Dashboard.Tests.Integration.Playwright;
// Browser coverage for terminal keyboard shortcuts, grid auto-fit, and the <aspire-scroll-to-bottom> custom element.
[RequiresFeature(TestFeature.Playwright)]
public class DashboardInteractionsTests : PlaywrightTestsBase<DashboardInteractionsTests.InteractionsDashboardServerFixture>
{
public DashboardInteractionsTests(InteractionsDashboardServerFixture dashboardServerFixture)
: base(dashboardServerFixture)
{
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task TerminalDockShortcut_UsesUnmodifiedPhysicalKeyAndPreservesInputGuard()
{
await RunTestAsync(async page =>
{
await page.SetContentAsync("""
<button id="control">Control</button>
<input id="input">
<textarea id="textarea"></textarea>
<div id="terminal"></div>
<fluent-text-field id="fluent"></fluent-text-field>
""");
await page.AddScriptTagAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, "wwwroot", "js", "app.js") });
await page.EvaluateAsync("""
() => {
const host = document.getElementById('fluent');
host.attachShadow({ mode: 'open' }).appendChild(document.createElement('input'));
const terminal = document.getElementById('terminal');
const view = terminal.attachShadow({ mode: 'open' }).appendChild(document.createElement('div'));
view.attachShadow({ mode: 'open' }).appendChild(document.createElement('textarea'));
}
""");
var cases = new (string Key, string Code, bool Shift, bool Alt, bool Ctrl, bool Meta, string Target, int? Expected)[]
{
("`", "Backquote", false, false, false, false, "control", 400),
("^", "Backquote", false, false, false, false, "control", 400),
("Dead", "Backquote", false, false, false, false, "control", 400),
("`", "BracketRight", false, false, false, false, "control", null),
("~", "Backquote", true, false, false, false, "control", null),
("`", "Backquote", false, true, false, false, "control", null),
("`", "Backquote", false, false, true, false, "control", null),
("`", "Backquote", false, false, false, true, "control", null),
("`", "Backquote", false, false, false, false, "input", null),
("^", "Backquote", false, false, false, false, "textarea", null),
("`", "Backquote", false, false, false, false, "terminal", null),
("^", "Backquote", false, false, false, false, "fluent", null),
("S", "KeyS", true, false, false, false, "control", 110),
("r", "KeyR", false, false, false, false, "control", 200)
};
foreach (var (key, code, shiftKey, altKey, ctrlKey, metaKey, target, expected) in cases)
{
var shortcuts = await page.EvaluateAsync<int[]>("""
({ key, code, shiftKey, altKey, ctrlKey, metaKey, target }) => {
const calls = [];
const registration = window.registerGlobalKeydownListener({
invokeMethodAsync: (_, shortcut) => {
calls.push(shortcut);
return Promise.resolve();
}
});
try {
const host = document.getElementById(target);
let input = host;
while (input.shadowRoot?.firstElementChild) {
input = input.shadowRoot.firstElementChild;
}
input.focus();
input.dispatchEvent(new KeyboardEvent('keydown', {
key, code, shiftKey, altKey, ctrlKey, metaKey,
bubbles: true, composed: true
}));
return calls;
} finally {
window.unregisterGlobalKeydownListener(registration);
}
}
""", new { key, code, shiftKey, altKey, ctrlKey, metaKey, target });
Assert.Equal(expected is { } shortcut ? [shortcut] : Array.Empty<int>(), shortcuts);
}
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task GridColumn_DoubleClickResizeHandle_AutoFitsColumnWidth()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
var grid = page.Locator(".main-grid").First;
await Assertions.Expect(grid).ToBeVisibleAsync();
// Guard against a Fluent UI Blazor rename of the resize handle marker: the auto-fit
// double-click handler keys off exactly these selectors, so if none is present the
// feature is silently dead and this count assertion surfaces it.
var handles = page.Locator(".main-grid [actual-resize-handle], .main-grid .resize-handle, .main-grid .col-width-draghandle");
Assert.True(await handles.CountAsync() > 0, "Expected at least one grid resize handle to be rendered.");
// Fluent writes the resolved template from GetGridTemplateColumns() to the table's *inline*
// grid-template-columns, so it is already populated at rest (typically with fr/auto tracks).
// auto-fit resolves every track to concrete px and rewrites the inline template with the
// fitted column, so the reliable end-to-end proof is that the inline value *changes* and is
// now an explicit px template.
var inlineBefore = await grid.EvaluateAsync<string>("el => el.style.gridTemplateColumns");
// The auto-fit behavior is a delegated document-level "dblclick" listener that keys off
// e.target.closest(".resize-handle"). Dispatch the dblclick straight onto the handle element
// rather than a pixel-precise click: the handle is a thin edge bar whose pointer-events are
// gated, so a coordinate double-click retargets to the header behind it and never reaches the
// handler. DispatchEvent fires a real bubbling MouseEvent on the exact element, which bubbles
// to the document listener exactly as a user double-click on the handle would.
await handles.First.DispatchEventAsync("dblclick");
await page.WaitForFunctionAsync(
@"before => {
const g = document.querySelector('.main-grid');
if (!g) { return false; }
const now = g.style.gridTemplateColumns;
return now.length > 0 && now !== before && /px/.test(now);
}",
inlineBefore)
.DefaultTimeout();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_DelaysRevealAndLayout_AndCancelsRevealWhenScrolledToBottom()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await page.Clock.InstallAsync();
await page.Clock.PauseAtAsync(DateTime.UtcNow.AddMinutes(1));
await AddScrollRegionAsync(page);
// Count geometry reads to distinguish deferred layout from merely hiding the button.
await page.EvaluateAsync("""
() => {
const region = document.getElementById('scroll-region');
const getBounds = region.getBoundingClientRect.bind(region);
window.__scrollRegionLayoutReads = 0;
region.getBoundingClientRect = () => {
window.__scrollRegionLayoutReads++;
return getBounds();
};
}
""");
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToHaveCountAsync(1);
await page.Clock.RunForAsync(100);
Assert.Equal("scroll-button scroll-to-bottom", await bottomButton.GetAttributeAsync("class"));
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.__scrollRegionLayoutReads"));
// Restore the initial bottom position while the reveal timer is still pending.
await page.EvaluateAsync("""
() => {
const region = document.getElementById('scroll-region');
region.scrollTop = region.scrollHeight;
region.dispatchEvent(new Event('scroll'));
}
""");
await page.Clock.RunForAsync(300);
Assert.Equal("scroll-button scroll-to-bottom", await bottomButton.GetAttributeAsync("class"));
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.__scrollRegionLayoutReads"));
await page.EvaluateAsync("""
() => {
document.getElementById('scroll-region').style.top = '160px';
window.dispatchEvent(new Event('resize'));
}
""");
await page.Clock.RunForAsync(300);
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.__scrollRegionLayoutReads"));
await page.EvaluateAsync("""
() => {
const region = document.getElementById('scroll-region');
region.scrollTop = 0;
region.dispatchEvent(new Event('scroll'));
}
""");
await page.Clock.RunForAsync(199);
Assert.Equal("scroll-button scroll-to-bottom", await bottomButton.GetAttributeAsync("class"));
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.__scrollRegionLayoutReads"));
await page.Clock.RunForAsync(50);
Assert.Equal("scroll-button scroll-to-bottom is-visible", await bottomButton.GetAttributeAsync("class"));
Assert.True(await page.EvaluateAsync<int>("() => window.__scrollRegionLayoutReads") > 0);
Assert.Equal(172, (await page.Locator(".scroll-buttons").BoundingBoxAsync())!.Y);
await page.Clock.ResumeAsync();
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_ContentResizeUpdatesVisibility()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await AddScrollRegionAsync(page);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
var clientHeight = await page.Locator("#scroll-region").EvaluateAsync<int>("region => region.clientHeight");
// Change only the content height: no scroll or resize event tells the button to update.
await page.Locator("#scroll-region .scroll-content").EvaluateAsync("content => content.style.height = '500px'");
await Assertions.Expect(bottomButton).ToBeHiddenAsync();
Assert.Equal(clientHeight, await page.Locator("#scroll-region").EvaluateAsync<int>("region => region.clientHeight"));
await page.Locator("#scroll-region .scroll-content").EvaluateAsync("content => content.style.height = '2000px'");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_SmoothScrollHidesImmediately_AndReachesNewBottom()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.NoPreference });
await AddScrollRegionAsync(page);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
// Headless browsers can skip every intermediate compositor frame under load. Capture the native
// smooth-scroll request, then dispatch scrollend explicitly so the content-growth behavior is deterministic.
await page.EvaluateAsync("""
() => {
const region = document.getElementById('scroll-region');
region.scrollTo = options => {
window.__scrollToBottomRequest = options;
};
document.querySelector('.scroll-to-bottom').addEventListener('click', event => {
window.__hiddenOnClick = event.currentTarget.hidden;
}, { once: true });
}
""");
await bottomButton.ClickAsync();
Assert.True(await page.EvaluateAsync<bool>("() => window.__hiddenOnClick"));
Assert.True(await page.EvaluateAsync<bool>("""
() => {
const region = document.getElementById('scroll-region');
return window.__scrollToBottomRequest.behavior === 'smooth' &&
window.__scrollToBottomRequest.top === region.scrollHeight;
}
"""));
await page.EvaluateAsync("""
() => {
const region = document.getElementById('scroll-region');
region.querySelector('.scroll-content').style.height = '4000px';
region.dispatchEvent(new Event('scrollend'));
}
""");
await page.WaitForFunctionAsync("""
() => {
const region = document.getElementById('scroll-region');
return region.scrollHeight >= 4000 &&
Math.abs(region.scrollHeight - region.clientHeight - region.scrollTop) < 1;
}
""").DefaultTimeout();
await Assertions.Expect(bottomButton).ToBeHiddenAsync();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_ReducedMotionScrollsImmediately()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await AddScrollRegionAsync(page);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
// Observe the destination in the same click dispatch, before any animation can run.
await page.EvaluateAsync("""
() => document.querySelector('.scroll-to-bottom').addEventListener('click', event => {
const region = document.getElementById('scroll-region');
window.__remainingOnClick = region.scrollHeight - region.clientHeight - region.scrollTop;
window.__hiddenOnClick = event.currentTarget.hidden;
}, { once: true })
""");
await bottomButton.ClickAsync();
Assert.Equal(0, await page.EvaluateAsync<int>("() => window.__remainingOnClick"));
Assert.True(await page.EvaluateAsync<bool>("() => window.__hiddenOnClick"));
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_ModalBackdropBlocksClicks()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await AddScrollRegionAsync(page);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
var buttonBounds = (await bottomButton.BoundingBoxAsync())!;
await page.EvaluateAsync("""
() => {
const dialog = document.createElement('fluent-dialog');
dialog.id = 'scroll-overlay-dialog';
dialog.setAttribute('type', 'modal');
dialog.innerHTML = '<p>Modal content</p>';
document.body.appendChild(dialog);
dialog.show();
}
""");
await page.WaitForFunctionAsync("() => document.getElementById('scroll-overlay-dialog').shadowRoot.querySelector('dialog').open").DefaultTimeout();
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
await page.Mouse.ClickAsync(buttonBounds.X + buttonBounds.Width / 2, buttonBounds.Y + buttonBounds.Height / 2);
Assert.Equal(0, await page.Locator("#scroll-region").EvaluateAsync<int>("region => region.scrollTop"));
Assert.Null(await bottomButton.GetAttributeAsync("hidden"));
await page.EvaluateAsync("""
() => {
const dialog = document.getElementById('scroll-overlay-dialog');
dialog.hide();
dialog.remove();
}
""");
await bottomButton.ClickAsync();
Assert.Equal(0, await page.Locator("#scroll-region").EvaluateAsync<int>(
"region => region.scrollHeight - region.clientHeight - region.scrollTop"));
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_CustomElementReplacesRegistration_AndCleansUpOnDisconnect()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce });
await AddScrollRegionAsync(page);
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveCountAsync(1);
await page.EvaluateAsync("""
() => {
window.__previousScrollButton = document.querySelector('.scroll-to-bottom');
const parent = document.getElementById('scroll-owner').cloneNode(true);
parent.id = 'next-scroll-owner';
parent.firstElementChild.id = 'next-scroll-region';
document.body.appendChild(parent);
window.__nextScrollRoot = document.querySelector('.scroll-buttons');
}
""");
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveCountAsync(1);
Assert.False(await page.EvaluateAsync<bool>("() => window.__previousScrollButton.isConnected"));
// Click the replaced button while its container is still connected. A detached
// container reports scrollTop=0 even if cleanup failed to remove the click listener.
Assert.Equal(new[] { 0, 0 }, await page.EvaluateAsync<int[]>("""
() => {
window.__previousScrollButton.click();
return [document.getElementById('scroll-region').scrollTop,
document.getElementById('next-scroll-region').scrollTop];
}
"""));
await page.EvaluateAsync("() => document.getElementById('scroll-owner').remove()");
Assert.True(await page.EvaluateAsync<bool>("() => window.__nextScrollRoot.isConnected"));
await page.Locator(".scroll-to-bottom").ClickAsync();
Assert.Equal(0, await page.Locator("#next-scroll-region").EvaluateAsync<int>(
"region => region.scrollHeight - region.clientHeight - region.scrollTop"));
await page.EvaluateAsync("""
() => {
window.__removedScrollOwner = document.getElementById('next-scroll-owner');
window.__removedScrollOwner.remove();
}
""");
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveCountAsync(0);
await page.EvaluateAsync("() => document.body.appendChild(window.__removedScrollOwner)");
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveCountAsync(1);
Assert.Equal(0, await page.EvaluateAsync<int>("""
() => {
const region = document.getElementById('next-scroll-region');
region.scrollTop = 0;
const button = document.querySelector('.scroll-to-bottom');
region.querySelector('aspire-scroll-to-bottom').remove();
button.click();
return region.scrollTop;
}
"""));
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveCountAsync(0);
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_RemainInactiveWhenRegionHasNoRoomForControl()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await AddScrollRegionAsync(page);
await Assertions.Expect(page.Locator(".scroll-to-bottom")).ToBeVisibleAsync();
await page.Locator("#scroll-region").EvaluateAsync("region => region.style.top = '-280px'");
var buttons = page.Locator(".scroll-buttons");
await Assertions.Expect(buttons).ToHaveCountAsync(1);
await page.EvaluateAsync("() => window.dispatchEvent(new Event('resize'))");
await Assertions.Expect(buttons).ToHaveClassAsync("scroll-buttons");
await Assertions.Expect(page.Locator(".scroll-to-bottom")).ToBeHiddenAsync();
await page.EvaluateAsync("""
() => {
document.getElementById('scroll-region').style.top = '0';
window.dispatchEvent(new Event('resize'));
}
""");
await Assertions.Expect(page.Locator(".scroll-to-bottom")).ToBeVisibleAsync();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_AncestorScrollRepositionsControl()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await AddScrollRegionAsync(page);
await page.EvaluateAsync("""
() => {
const owner = document.getElementById('scroll-owner');
owner.style.cssText = 'position:fixed;left:0;top:100px;width:450px;height:400px;overflow:auto;';
document.getElementById('scroll-region').style.position = 'static';
const spacer = document.createElement('div');
spacer.style.height = '1000px';
owner.appendChild(spacer);
}
""");
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
var originalBounds = (await page.Locator(".scroll-buttons").BoundingBoxAsync())!;
var region = page.Locator("#scroll-region");
var originalRegionBounds = (await region.BoundingBoxAsync())!;
// Scroll the ancestor without synthesizing resize or bubbling scroll events. The
// window capture listener must follow the container while its contents stay still.
await page.Locator("#scroll-owner").EvaluateAsync("owner => owner.scrollTop = 40");
await page.WaitForFunctionAsync("""
expectedBottom => Math.abs(document.querySelector('.scroll-buttons').getBoundingClientRect().bottom - expectedBottom) < 1
""", (double)(originalBounds.Y + originalBounds.Height - 40)).DefaultTimeout();
Assert.Equal(originalBounds.Y, (await page.Locator(".scroll-buttons").BoundingBoxAsync())!.Y);
Assert.Equal(originalRegionBounds.Y - 40, (await region.BoundingBoxAsync())!.Y);
Assert.Equal(0, await region.EvaluateAsync<int>("element => element.scrollTop"));
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
// The region is still inside the viewport, but fully above its ancestor's scrollport.
await page.Locator("#scroll-owner").EvaluateAsync("owner => owner.scrollTop = 300");
await Assertions.Expect(page.Locator(".scroll-buttons")).ToHaveClassAsync("scroll-buttons");
await Assertions.Expect(bottomButton).ToBeHiddenAsync();
await page.Locator("#scroll-owner").EvaluateAsync("owner => owner.scrollTop = 0");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
await page.WaitForFunctionAsync("""
expectedBottom => Math.abs(document.querySelector('.scroll-buttons').getBoundingClientRect().bottom - expectedBottom) < 1
""", (double)(originalBounds.Y + originalBounds.Height)).DefaultTimeout();
Assert.Equal(originalRegionBounds.Y, (await region.BoundingBoxAsync())!.Y);
Assert.Equal(0, await region.EvaluateAsync<int>("element => element.scrollTop"));
});
}
[Theory]
[InlineData("hidden")]
[InlineData("clip")]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_NestedClippingAncestorConstrainsControl(string overflow)
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
await AddScrollRegionAsync(page);
await page.EvaluateAsync("""
overflow => {
const owner = document.getElementById('scroll-owner');
owner.style.cssText = 'position:fixed;left:0;top:100px;width:200px;height:200px;border:10px solid;';
owner.style.overflow = overflow;
const region = document.getElementById('scroll-region');
region.style.position = 'static';
const wrapper = document.createElement('div');
wrapper.style.cssText = 'width:400px;height:300px;overflow:visible;';
owner.appendChild(wrapper);
wrapper.appendChild(region);
window.dispatchEvent(new Event('resize'));
}
""", overflow);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToBeVisibleAsync();
var root = page.Locator(".scroll-buttons");
await page.WaitForFunctionAsync("""
() => document.querySelector('.scroll-buttons').style.left === '100px'
""").DefaultTimeout();
var bounds = (await root.BoundingBoxAsync())!;
Assert.Equal(100, bounds.X + bounds.Width / 2);
Assert.Equal(122, bounds.Y);
Assert.Equal(156, bounds.Height);
// Keep the region in the viewport, but move it past the horizontal clip edge.
await page.EvaluateAsync("""
() => {
document.getElementById('scroll-region').style.marginLeft = '220px';
window.dispatchEvent(new Event('resize'));
}
""");
await Assertions.Expect(root).ToHaveClassAsync("scroll-buttons");
await Assertions.Expect(bottomButton).ToBeHiddenAsync();
});
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ScrollButtons_UsesLabelFromCustomElement()
{
await RunTestAsync(async page =>
{
await GoToResourcesAndWaitAsync(page);
Assert.Null(await page.Locator("body").GetAttributeAsync("data-scroll-to-bottom-label"));
await AddScrollRegionAsync(page);
var bottomButton = page.Locator(".scroll-to-bottom");
await Assertions.Expect(bottomButton).ToHaveAttributeAsync("aria-label", "Jump to latest");
await Assertions.Expect(bottomButton).ToHaveAttributeAsync("title", "Jump to latest");
});
}
private static Task AddScrollRegionAsync(IPage page)
{
// Connect the marker before adding the content, as can happen while Blazor applies a render
// batch. The Resources page has no marker of its own, so registration comes from this one.
return page.EvaluateAsync("""
() => {
const owner = document.createElement('div');
owner.id = 'scroll-owner';
owner.innerHTML = `
<div id="scroll-region" style="position:fixed;left:0;top:100px;width:400px;height:300px;overflow:auto;">
<aspire-scroll-to-bottom hidden data-scroll-to-bottom-label="Jump to latest"></aspire-scroll-to-bottom>
</div>`;
document.body.appendChild(owner);
const content = document.createElement('div');
content.className = 'scroll-content';
content.style.height = '2000px';
document.getElementById('scroll-region').appendChild(content);
}
""");
}
private static async Task GoToResourcesAndWaitAsync(IPage page)
{
await page.GotoAsync("/");
await Assertions
.Expect(page.GetByText(InteractionsDashboardServerFixture.ParentResourceName).First)
.ToBeVisibleAsync();
}
public sealed class InteractionsDashboardServerFixture : DashboardServerFixture
{
public const string ParentResourceName = "parentapp";
protected override IReadOnlyList<ResourceViewModel> Resources =>
[
ModelTestHelpers.CreateResource(
resourceName: ParentResourceName,
resourceType: KnownResourceTypes.Project,
state: KnownResourceState.Running,
urls:
[
new UrlViewModel("http", new Uri("about:blank#parent-url"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)
]),
];
}
}