// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using System.Text.Json;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.TestUtilities;
using Aspire.Tests.Shared.DashboardModel;
using Deque.AxeCore.Commons;
using Deque.AxeCore.Playwright;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Playwright;
using Xunit;
namespace Aspire.Dashboard.Tests.Integration.Playwright;
/// <summary>
/// Automated WCAG 2.0 / 2.1 (levels A and AA) accessibility checks for the dashboard's primary
/// pages, driven by the axe-core engine (via the MIT-licensed <c>Deque.AxeCore.Playwright</c>
/// wrapper) against a real dashboard server. Both the light and dark themes are scanned because
/// color-contrast outcomes differ between them, the primary page is additionally scanned across
/// mobile, tablet and desktop viewports, the key dialogs/flyout panels are opened and scanned (a
/// surface the resting-state page matrix never reaches), and code block syntax colors are checked
/// for AA contrast in both themes (a surface axe can't reach on its own).
/// </summary>
[RequiresFeature(TestFeature.Playwright)]
public sealed class AccessibilityTests : PlaywrightTestsBase<AccessibilityTests.AccessibilityDashboardServerFixture>
{
// WCAG 2.0 and 2.1, levels A and AA - the conformance target the dashboard commits to. axe's
// "best-practice" and experimental rule sets are intentionally excluded so the gate tracks an
// actual accessibility standard rather than axe's opinionated extras.
// See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#axe-core-tags.
private static readonly string[] s_wcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
// axe impact levels that fail the build. Serious and critical issues are unambiguous barriers
// for assistive-technology users. Moderate/minor findings are still reported in the failure
// message (when a serious/critical issue trips the gate) but don't fail on their own, which
// keeps the gate stable against third-party (Fluent UI) shadow-DOM churn we don't control.
private static readonly HashSet<string> s_failingImpacts = new(StringComparer.OrdinalIgnoreCase) { "serious", "critical" };
// Rule IDs intentionally excluded from the gate. Keep this empty unless a violation is a
// confirmed false positive or lives entirely in third-party (Fluent UI) shadow DOM that
// cannot be fixed from this repo; document each entry with a tracking issue link when added.
private static readonly HashSet<string> s_allowedRuleIds = new(StringComparer.OrdinalIgnoreCase);
// WCAG 2.0 SC 1.4.3 (AA) minimum contrast ratio for normal-size text.
private const double WcagAaContrastMinimum = 4.5;
// Mirrors ViewportInformation.MobileCutoffPixelWidth: at or below this width the dashboard renders
// its mobile chrome (the page title is not teleported into the top bar). Kept as a local literal so
// the test doesn't depend on dashboard internals.
private const int MobileCutoffPixelWidth = 768;
// The default desktop viewport used by the per-theme matrix and the code block contrast checks.
private static readonly ViewportSize s_desktopViewport = new() { Width = 1280, Height = 900 };
public AccessibilityTests(AccessibilityDashboardServerFixture dashboardServerFixture)
: base(dashboardServerFixture)
{
}
[Theory]
[OuterloopTest("Resource-intensive Playwright browser test")]
[InlineData("/", "Light")]
[InlineData("/", "Dark")]
[InlineData("/consolelogs", "Light")]
[InlineData("/consolelogs", "Dark")]
[InlineData("/structuredlogs", "Light")]
[InlineData("/structuredlogs", "Dark")]
[InlineData("/traces", "Light")]
[InlineData("/traces", "Dark")]
[InlineData("/metrics", "Light")]
[InlineData("/metrics", "Dark")]
public Task DashboardPage_HasNoSeriousOrCriticalWcagViolations(string relativeUrl, string theme)
=> AssertNoBlockingWcagViolationsAsync(relativeUrl, theme, s_desktopViewport);
// Responsive coverage: re-run the same WCAG gate on the primary page across mobile, tablet and
// desktop widths so layout-sensitive barriers - reflow, focus order, and content that overlaps or
// is clipped once the app switches to its mobile chrome (at/below MobileCutoffPixelWidth) - are
// caught in addition to the per-theme desktop matrix above. Light theme only; color-contrast, the
// theme-sensitive axis, is already exercised in both themes by the matrix above.
[Theory]
[OuterloopTest("Resource-intensive Playwright browser test")]
[InlineData("Mobile", 375, 812)]
[InlineData("Tablet", 768, 1024)]
[InlineData("Desktop", 1280, 900)]
public Task DashboardHomePage_IsAccessibleAcrossViewports(string viewportName, int width, int height)
=> AssertNoBlockingWcagViolationsAsync("/", "Light", new ViewportSize { Width = width, Height = height }, viewportName);
// Dialogs and flyout panels are only reachable behind a click, so the resting-state page matrix
// above never audits them. The dashboard's dialog chrome - panel background, input wells, and the
// primary/secondary action buttons that read very differently in dark theme - is exactly the kind
// of surface where contrast regressions hide. Open each key surface and run the same
// serious/critical WCAG gate against it in both themes (color-contrast is the theme-sensitive axis).
[Theory]
[OuterloopTest("Resource-intensive Playwright browser test")]
[InlineData("Settings", "Light")]
[InlineData("Settings", "Dark")]
[InlineData("Filter", "Light")]
[InlineData("Filter", "Dark")]
public Task DashboardDialog_HasNoSeriousOrCriticalWcagViolations(string surface, string theme)
{
var (startUrl, label, openSurfaceAsync) = s_dialogSurfaces[surface];
return AssertNoBlockingWcagViolationsAsync(startUrl, theme, s_desktopViewport, openSurfaceAsync: openSurfaceAsync, surfaceLabel: label);
}
[Theory]
[OuterloopTest("Resource-intensive Playwright browser test")]
[InlineData("Light", "fluent-text-field", "root")]
[InlineData("Light", "fluent-search", "root")]
[InlineData("Light", "fluent-number-field", "root")]
[InlineData("Light", "fluent-text-area", "control")]
[InlineData("Dark", "fluent-text-field", "root")]
[InlineData("Dark", "fluent-search", "root")]
[InlineData("Dark", "fluent-number-field", "root")]
[InlineData("Dark", "fluent-text-area", "control")]
public async Task FluentDelegatedInput_ShowsVisibleFocusIndicator(string theme, string controlName, string partName)
{
var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress();
await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
BaseURL = baseUrl,
ViewportSize = s_desktopViewport
});
await context.AddCookiesAsync([new Cookie { Name = "currentTheme", Value = theme, Url = baseUrl }]);
var page = await context.NewPageAsync();
await page.GotoAsync("/").DefaultTimeout();
await page.WaitForSelectorAsync("body:not(.before-upgrade)").DefaultTimeout();
await page.WaitForSelectorAsync(
$"html[data-theme='{theme.ToLowerInvariant()}']",
new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached }).DefaultTimeout();
await Assertions.Expect(page.GetByText("frontend", new PageGetByTextOptions { Exact = true }).First).ToBeVisibleAsync();
await WaitForComponentsAndFontsAsync(page);
ILocator control;
if (string.Equals(controlName, "fluent-search", StringComparison.Ordinal))
{
control = page.Locator("fluent-search[name='resources-search']");
}
else
{
await page.EvaluateAsync(
"""
async controlName => {
await customElements.whenDefined(controlName);
const control = document.createElement(controlName);
control.id = 'delegated-focus-probe';
control.style.cssText = 'position:fixed;left:-99999px;top:0;';
document.body.appendChild(control);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
""",
controlName).DefaultTimeout();
control = page.Locator("#delegated-focus-probe");
}
await AssertVisibleFocusIndicatorAsync(control, partName);
}
[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task DarkAccentButtonInteractionStates_MeetWcagAaContrast()
{
var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress();
await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
BaseURL = baseUrl,
ViewportSize = s_desktopViewport
});
await context.AddCookiesAsync([new Cookie { Name = "currentTheme", Value = "Dark", Url = baseUrl }]);
var page = await context.NewPageAsync();
await page.GotoAsync("/").DefaultTimeout();
await page.WaitForSelectorAsync("body:not(.before-upgrade)").DefaultTimeout();
await page.WaitForSelectorAsync(
"html[data-theme='dark']",
new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached }).DefaultTimeout();
await WaitForComponentsAndFontsAsync(page);
await page.EvaluateAsync("""
async () => {
await customElements.whenDefined('fluent-button');
const button = document.createElement('fluent-button');
button.id = 'accent-contrast-probe';
button.setAttribute('appearance', 'accent');
button.textContent = 'Primary action';
button.style.cssText = 'position:fixed;left:16px;top:16px;z-index:10000;';
document.body.appendChild(button);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
""").DefaultTimeout();
var button = page.Locator("#accent-contrast-probe");
var states = new List<(string Name, (int R, int G, int B) Foreground, (int R, int G, int B) Background)>();
var rest = await ReadFluentControlColorsAsync(button);
states.Add(("rest", rest.Foreground, rest.Background));
await button.HoverAsync();
var hover = await ReadFluentControlColorsAsync(button);
states.Add(("hover", hover.Foreground, hover.Background));
await page.Mouse.DownAsync();
var active = await ReadFluentControlColorsAsync(button);
states.Add(("active", active.Foreground, active.Background));
await page.Mouse.UpAsync();
var failures = states
.Select(state => (state.Name, state.Foreground, state.Background, Ratio: ContrastRatio(state.Foreground, state.Background)))
.Where(state => state.Ratio < WcagAaContrastMinimum)
.Select(state =>
$" {state.Name}: {state.Ratio:F2}:1 " +
$"(text rgb({state.Foreground.R},{state.Foreground.G},{state.Foreground.B}) on background rgb({state.Background.R},{state.Background.G},{state.Background.B}))")
.ToList();
Assert.True(
failures.Count == 0,
$"Dark accent button state contrast falls below the WCAG AA {WcagAaContrastMinimum:F1}:1 minimum:{Environment.NewLine}{string.Join(Environment.NewLine, failures)}");
}
// Maps each dialog surface (the InlineData key) to the page it's opened from, a human-readable
// label used in failure messages, and the interaction that opens it and waits for it to render.
// Settings is a right-aligned flyout panel reachable from every page's header; Filter is the
// FilterDialog panel opened from the structured logs toolbar.
private static readonly IReadOnlyDictionary<string, (string StartUrl, string Label, Func<IPage, Task> OpenSurfaceAsync)> s_dialogSurfaces =
new Dictionary<string, (string, string, Func<IPage, Task>)>(StringComparer.Ordinal)
{
["Settings"] = ("/", "Settings flyout", OpenSettingsFlyoutAsync),
["Filter"] = ("/structuredlogs", "Add filter dialog", OpenAddFilterDialogAsync),
};
private static async Task OpenSettingsFlyoutAsync(IPage page)
{
// The settings button lives in the top header on every page (MainLayout.SettingsButtonId).
await page.Locator("#dashboard-settings-button").ClickAsync();
// The settings panel is a right-aligned fluent-dialog with a fixed id (MainLayout.SettingsDialogId).
// Wait on a light-DOM descendant rather than the <fluent-dialog> host: Fluent projects the dialog
// body through a slot, so the custom-element host carries no layout box and never satisfies
// Playwright's visibility check even once the panel is fully open. .input-container wraps each
// settings group in SettingsDialog.razor and is a reliable "content has rendered" signal.
await Assertions.Expect(page.Locator("fluent-dialog#SettingsDialog .input-container").First).ToBeVisibleAsync();
}
private static async Task OpenAddFilterDialogAsync(IPage page)
{
// The structured logs toolbar's "Add filter" button carries the localized aria-label from the
// StructuredFiltering resource; reading it back from the same resource keeps the selector correct
// regardless of test culture rather than hard-coding the English text.
await page.Locator($"fluent-button[aria-label='{StructuredFiltering.AddFilter}']").ClickAsync();
// FilterDialog opens as a right-aligned panel with no id, so wait for its distinctive
// .filter-button-container (see FilterDialog.razor) to confirm the dialog body has rendered.
await Assertions.Expect(page.Locator("fluent-dialog .filter-button-container")).ToBeVisibleAsync();
}
/// <summary>
/// Verifies that every highlight.js syntax-token color used in code blocks (the text visualizer and
/// rendered markdown) meets the WCAG 2.0 AA 4.5:1 minimum contrast against the code block
/// background, in both themes. axe's own color-contrast rule can't cover these because a
/// syntax-highlighted block isn't present on the scanned pages, so this renders an off-screen block
/// that mirrors the dialog's DOM, reads the computed colors, and asserts the ratio here.
/// </summary>
[Theory]
[OuterloopTest("Resource-intensive Playwright browser test")]
[InlineData("Light")]
[InlineData("Dark")]
public async Task CodeblockSyntaxColors_MeetWcagAaContrast(string theme)
{
var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress();
await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
BaseURL = baseUrl,
ViewportSize = s_desktopViewport
});
await context.AddCookiesAsync([new Cookie { Name = "currentTheme", Value = theme, Url = baseUrl }]);
var page = await context.NewPageAsync();
await page.GotoAsync("/").DefaultTimeout();
await page.WaitForSelectorAsync("body:not(.before-upgrade)").DefaultTimeout();
await page.WaitForSelectorAsync(
$"html[data-theme='{theme.ToLowerInvariant()}']",
new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached }).DefaultTimeout();
// Settle component hydration + fonts before probing so the code block's computed colors are read
// against fully applied theme styles (see WaitForComponentsAndFontsAsync).
await WaitForComponentsAndFontsAsync(page);
var probeJson = await page.EvaluateAsync<string>(CodeblockColorProbeScript);
using var probe = JsonDocument.Parse(probeJson);
var failures = new List<string>();
foreach (var surface in probe.RootElement.GetProperty("surfaces").EnumerateArray())
{
var surfaceName = surface.GetProperty("name").GetString();
var background = ReadRgb(surface.GetProperty("bg"));
foreach (var token in surface.GetProperty("tokens").EnumerateArray())
{
var foreground = ReadRgb(token);
var ratio = ContrastRatio(foreground, background);
if (ratio < WcagAaContrastMinimum)
{
failures.Add(
$" {surfaceName} / {token.GetProperty("name").GetString()}: {ratio:F2}:1 " +
$"(text rgb({foreground.R},{foreground.G},{foreground.B}) on background rgb({background.R},{background.G},{background.B}))");
}
}
}
Assert.True(
failures.Count == 0,
$"{failures.Count} code block syntax color(s) fall below the WCAG AA {WcagAaContrastMinimum:F1}:1 minimum " +
$"in the {theme} theme:{Environment.NewLine}{string.Join(Environment.NewLine, failures)}");
}
private async Task AssertNoBlockingWcagViolationsAsync(string relativeUrl, string theme, ViewportSize viewport, string? viewportLabel = null, Func<IPage, Task>? openSurfaceAsync = null, string? surfaceLabel = null)
{
var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress();
await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
BaseURL = baseUrl,
ViewportSize = viewport
});
// The dashboard resolves its theme from the `currentTheme` cookie on boot (see
// wwwroot/js/app-theme.js). Seeding it before the first navigation makes the scanned
// theme deterministic instead of depending on the OS/browser "System" preference.
await context.AddCookiesAsync([new Cookie { Name = "currentTheme", Value = theme, Url = baseUrl }]);
var page = await context.NewPageAsync();
await page.GotoAsync(relativeUrl).DefaultTimeout();
// Wait until Blazor has upgraded the Fluent web components - app.js removes the
// `before-upgrade` class from <body> once the components are ready (before then <body> is
// visibility:hidden). Scanning earlier would audit the pre-hydration shell.
await page.WaitForSelectorAsync("body:not(.before-upgrade)").DefaultTimeout();
// Confirm the requested theme actually applied (app-theme.js sets data-theme on <html>),
// otherwise a contrast scan could silently run against the wrong palette.
await page.WaitForSelectorAsync(
$"html[data-theme='{theme.ToLowerInvariant()}']",
new PageWaitForSelectorOptions { State = WaitForSelectorState.Attached }).DefaultTimeout();
// Readiness signal that content has rendered. On desktop the page title teleports into the top
// bar as <h1 class="page-header"> (AspirePageContentLayout); at/below MobileCutoffPixelWidth
// that teleport doesn't happen, so fall back to the always-present <main> region.
if (viewport.Width > MobileCutoffPixelWidth)
{
await Assertions.Expect(page.Locator("h1.page-header")).ToBeVisibleAsync();
}
else
{
await Assertions.Expect(page.Locator("main")).ToBeVisibleAsync();
}
await WaitForPageContentAsync(page, relativeUrl);
// Ensure every Fluent web component has finished upgrading and fonts have loaded before the
// page is treated as scannable (see WaitForComponentsAndFontsAsync). This settles the base
// page's comboboxes/buttons so the open click below and the scan don't race hydration.
await WaitForComponentsAndFontsAsync(page);
// Open an interactive surface (dialog, flyout panel) when requested. The caller's delegate both
// triggers the surface and waits for it to render, so the scan below audits the page with that
// surface open. Done before freezing animations so the surface's own open/fade animation is
// pinned to its end state too (see below).
if (openSurfaceAsync is not null)
{
await openSurfaceAsync(page);
// The just-opened dialog mounts its own custom elements (e.g. the Settings language
// <fluent-select>); wait for those to upgrade too so the scan doesn't sample a
// half-hydrated combobox inside the panel.
await WaitForComponentsAndFontsAsync(page);
}
// Collapse all CSS animations/transitions to their end state before scanning. axe's
// color-contrast check multiplies an element's foreground by its (and its ancestors')
// computed opacity, so if it samples while something is still animating in - e.g. the
// FluentMessageBar fades in via a 1.5s `fadein` opacity animation - it reads a washed-out
// effective color (settled text blended toward the background) and reports a false failure.
// Forcing animation/transition durations to 0 pins every element at its resting opacity/color.
await page.AddStyleTagAsync(new PageAddStyleTagOptions
{
Content = "*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }"
});
// Yield two animation frames so the freeze stylesheet above is applied and painted before axe
// samples computed styles; otherwise axe can read a value still interpolating from the frame the
// stylesheet was injected on.
await page.EvaluateAsync("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))").DefaultTimeout();
var axeResults = await page.RunAxe(new AxeRunOptions
{
RunOnly = new RunOnlyOptions { Type = "tag", Values = [.. s_wcagTags] }
});
var blockingViolations = axeResults.Violations
.Where(v => v.Impact is not null
&& s_failingImpacts.Contains(v.Impact)
&& !s_allowedRuleIds.Contains(v.Id))
.ToList();
Assert.True(
blockingViolations.Count == 0,
BuildFailureMessage(axeResults, blockingViolations, relativeUrl, theme, viewportLabel, surfaceLabel));
}
// Blazor + Fluent web components hydrate asynchronously and independently: app.js clears the
// <body> `before-upgrade` class as soon as the *first* custom element upgrades, but other component
// types on the page (notably the Settings language <fluent-select> and the filter comboboxes) can
// still be mid-upgrade at that moment. axe scanning a half-upgraded combobox reads a transient
// role/accessible-name and yields non-deterministic pass/fail results. Wait for every custom element
// currently in the DOM to finish upgrading, then for web fonts to finish loading, so the scan (and
// any preceding interaction) samples a fully settled, stable page.
private static async Task WaitForComponentsAndFontsAsync(IPage page)
{
await page.EvaluateAsync("""
async () => {
const undefinedNames = new Set();
// :not(:defined) matches only not-yet-upgraded custom elements - built-in elements are
// always :defined - so this collects exactly the component types still hydrating.
for (const el of document.querySelectorAll(':not(:defined)')) {
undefinedNames.add(el.localName);
}
await Promise.all([...undefinedNames].map(name => customElements.whenDefined(name)));
// document.fonts.ready resolves once the font loads triggered so far settle, so text
// metrics/rendering are stable for the scan (the dashboard ships a custom body font).
await document.fonts.ready;
}
""").DefaultTimeout();
}
private static Task WaitForPageContentAsync(IPage page, string relativeUrl)
{
var path = relativeUrl.Split('?', 2)[0].TrimEnd('/');
var content = path switch
{
"" => page.GetByText("frontend", new PageGetByTextOptions { Exact = true }).First,
"/consolelogs" => page.GetByText(ConsoleLogs.ConsoleLogsNoLogsFound, new PageGetByTextOptions { Exact = true }).First,
"/structuredlogs" => page.GetByText(StructuredLogs.StructuredLogsNoLogsFound, new PageGetByTextOptions { Exact = true }).First,
"/traces" => page.GetByText(Traces.TracesNoTraces, new PageGetByTextOptions { Exact = true }).First,
"/metrics" => page.GetByText(Metrics.MetricsSelectAResource, new PageGetByTextOptions { Exact = true }).First,
_ => throw new InvalidOperationException($"No accessibility readiness signal is configured for '{relativeUrl}'.")
};
// The shell can hydrate before each page's asynchronous data load finishes. Wait for content
// that is specific to the requested route so axe never scans a transient empty page.
return Assertions.Expect(content).ToBeVisibleAsync();
}
private static string BuildFailureMessage(AxeResult results, IReadOnlyList<AxeResultItem> blocking, string relativeUrl, string theme, string? viewportLabel = null, string? surfaceLabel = null)
{
var contextParts = new List<string> { $"{theme} theme" };
if (viewportLabel is not null)
{
contextParts.Add($"{viewportLabel} viewport");
}
if (surfaceLabel is not null)
{
contextParts.Add(surfaceLabel);
}
var scanContext = string.Join(", ", contextParts);
var sb = new StringBuilder();
sb.AppendLine($"Found {blocking.Count} serious/critical WCAG 2.x A/AA accessibility violation(s) on '{relativeUrl}' ({scanContext}):");
sb.AppendLine();
foreach (var violation in blocking)
{
sb.AppendLine($" [{violation.Impact}] {violation.Id}: {violation.Help}");
sb.AppendLine($" {violation.HelpUrl}");
// Show a few offending nodes (selector + markup) to make the failure actionable without
// re-running axe locally. Cap the count/length so the message stays readable. Use the
// AxeSelector's string form rather than .Selector because Fluent UI renders into shadow
// DOM, and .Selector throws for shadow-nested nodes (it can't be a single CSS selector).
foreach (var node in violation.Nodes.Take(5))
{
sb.AppendLine($" target: {node.Target}");
sb.AppendLine($" html: {Truncate(node.Html, 200)}");
// For color-contrast (and similar) rules, axe records the measured values - e.g.
// foreground/background color, ratio and required ratio - in the per-node "any"
// checks. Surfacing those messages makes the fix precise (which color, which ratio).
foreach (var check in node.Any)
{
sb.AppendLine($" why: {check.Message}");
}
}
sb.AppendLine();
}
// Surface non-blocking (moderate/minor) findings and any allowlisted rules too, so the
// failure captures the full accessibility picture for the page in one place.
var other = results.Violations.Where(v => !blocking.Contains(v)).ToList();
if (other.Count > 0)
{
sb.AppendLine($"Additional non-blocking violations ({other.Count}): "
+ string.Join(", ", other.Select(v => $"{v.Id}({v.Impact ?? "n/a"})")));
}
return sb.ToString();
}
private static string Truncate(string? value, int maxLength)
=> string.IsNullOrEmpty(value)
? string.Empty
: value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
private static (int R, int G, int B) ReadRgb(JsonElement element)
=> (element.GetProperty("r").GetInt32(), element.GetProperty("g").GetInt32(), element.GetProperty("b").GetInt32());
private static async Task AssertVisibleFocusIndicatorAsync(ILocator host, string partName)
{
var focusJson = await host.EvaluateAsync<string>(
"""
(element, partName) => {
const focusTarget = element.shadowRoot?.querySelector(
'input, textarea, [role="combobox"], [tabindex]:not([tabindex="-1"])');
if (!focusTarget) {
throw new Error(`No delegated focus target found for ${element.localName}.`);
}
focusTarget.focus();
const part = element.shadowRoot.querySelector(`[part~="${partName}"]`);
if (!part) {
const availableParts = [...element.shadowRoot.querySelectorAll('[part]')]
.map(node => node.getAttribute('part'))
.join(', ');
throw new Error(
`No ${partName} part found for ${element.localName}. Available parts: ${availableParts}.`);
}
const style = getComputedStyle(part);
return JSON.stringify({
focusWithin: element.matches(':focus-within'),
focusVisible: element.matches(':focus-visible'),
outlineStyle: style.outlineStyle,
outlineWidth: Number.parseFloat(style.outlineWidth),
outlineColor: style.outlineColor
});
}
""",
partName);
using var focus = JsonDocument.Parse(focusJson);
var root = focus.RootElement;
var outlineStyle = root.GetProperty("outlineStyle").GetString();
var outlineWidth = root.GetProperty("outlineWidth").GetDouble();
var outlineColor = root.GetProperty("outlineColor").GetString();
var hasOpaqueOutline = !string.Equals(outlineColor, "transparent", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(outlineColor, "rgba(0, 0, 0, 0)", StringComparison.OrdinalIgnoreCase);
Assert.True(root.GetProperty("focusWithin").GetBoolean(), $"Expected {await host.EvaluateAsync<string>("element => element.localName")} to contain delegated focus.");
Assert.True(
!string.Equals(outlineStyle, "none", StringComparison.Ordinal) && outlineWidth >= 2 && hasOpaqueOutline,
$"Expected a visible focus outline on the {partName} part, but got style '{outlineStyle}', width {outlineWidth}px, color {outlineColor}, host :focus-visible={root.GetProperty("focusVisible").GetBoolean()}.");
}
private static async Task<((int R, int G, int B) Foreground, (int R, int G, int B) Background)> ReadFluentControlColorsAsync(ILocator host)
{
var colorsJson = await host.EvaluateAsync<string>(
"""
element => {
function parseRgb(s) {
s = String(s).trim();
let m = s.match(/rgba?\(([^)]+)\)/);
if (m) {
const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(parseFloat);
return [p[0], p[1], p[2]];
}
m = s.match(/color\(srgb\s+([^)]+)\)/);
if (m) {
const p = m[1].split(/[ \/]+/).filter(Boolean).map(parseFloat);
return [Math.round(p[0] * 255), Math.round(p[1] * 255), Math.round(p[2] * 255)];
}
throw new Error('unparseable color: ' + s);
}
const control = element.shadowRoot?.querySelector('[part~="control"]');
if (!control) {
throw new Error(`No control part found for ${element.localName}.`);
}
const style = getComputedStyle(control);
const foreground = parseRgb(style.color);
const background = parseRgb(style.backgroundColor);
return JSON.stringify({
fg: { r: foreground[0], g: foreground[1], b: foreground[2] },
bg: { r: background[0], g: background[1], b: background[2] }
});
}
""");
using var colors = JsonDocument.Parse(colorsJson);
var root = colors.RootElement;
return (ReadRgb(root.GetProperty("fg")), ReadRgb(root.GetProperty("bg")));
}
// WCAG 2.x relative luminance and contrast ratio.
// See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance and #dfn-contrast-ratio.
private static double ContrastRatio((int R, int G, int B) foreground, (int R, int G, int B) background)
{
var l1 = RelativeLuminance(foreground.R, foreground.G, foreground.B);
var l2 = RelativeLuminance(background.R, background.G, background.B);
var (lighter, darker) = l1 >= l2 ? (l1, l2) : (l2, l1);
return (lighter + 0.05) / (darker + 0.05);
}
private static double RelativeLuminance(int r, int g, int b)
{
static double Channel(int component)
{
var c = component / 255.0;
return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
}
return (0.2126 * Channel(r)) + (0.7152 * Channel(g)) + (0.0722 * Channel(b));
}
// Render the two production syntax-highlight surfaces off-screen: Text Visualizer inherits the
// dialog surface, while rendered markdown paints its own code-block surface. The active theme is
// whatever the page booted with (data-theme on <html>). WCAG math is done in C# in the test.
private const string CodeblockColorProbeScript = """
async () => {
function parseRgb(s) {
s = String(s).trim();
let m = s.match(/rgba?\(([^)]+)\)/);
if (m) { const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(parseFloat); return [p[0], p[1], p[2]]; }
m = s.match(/color\(srgb\s+([^)]+)\)/);
if (m) { const p = m[1].split(/[ \/]+/).filter(Boolean).map(parseFloat); return [Math.round(p[0] * 255), Math.round(p[1] * 255), Math.round(p[2] * 255)]; }
throw new Error('unparseable color: ' + s);
}
const groups = { default: 'hljs', comment: 'hljs-comment', variable: 'hljs-variable', literal: 'hljs-literal', attribute: 'hljs-attribute', string: 'hljs-string', section: 'hljs-section', keyword: 'hljs-keyword' };
const theme = document.documentElement.getAttribute('data-theme');
async function readSurface(name, container, getBackgroundElement, line) {
document.body.appendChild(container);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const backgroundElement = getBackgroundElement();
if (!backgroundElement) {
throw new Error(`No production background element found for ${name}.`);
}
const bg = parseRgb(getComputedStyle(backgroundElement).backgroundColor);
const tokens = [];
for (const tokenName of Object.keys(groups)) {
const cls = groups[tokenName];
let element;
if (cls === 'hljs') {
element = line;
} else {
element = document.createElement('span');
element.className = cls;
element.textContent = 'x';
line.appendChild(element);
}
const fg = parseRgb(getComputedStyle(element).color);
tokens.push({ name: tokenName, r: fg[0], g: fg[1], b: fg[2] });
}
container.remove();
return { name: name, bg: { r: bg[0], g: bg[1], b: bg[2] }, tokens: tokens };
}
await customElements.whenDefined('fluent-dialog');
const dialog = document.createElement('fluent-dialog');
dialog.style.cssText = 'position:fixed;left:-99999px;top:0;';
const textVisualizer = document.createElement('div');
textVisualizer.className = 'text-visualizer-container';
const overflow = document.createElement('div');
overflow.className = 'log-overflow';
const visualizerLine = document.createElement('span');
visualizerLine.className = 'log-content highlight-line hljs theme-a11y-' + theme + '-min';
visualizerLine.textContent = 'sample';
overflow.appendChild(visualizerLine);
textVisualizer.appendChild(overflow);
dialog.appendChild(textVisualizer);
const markdown = document.createElement('div');
markdown.className = 'markdown-container';
markdown.style.cssText = 'position:fixed;left:-99999px;top:0;';
const codeBlock = document.createElement('div');
codeBlock.className = 'code-block';
const markdownCode = document.createElement('code');
markdownCode.className = 'hljs theme-a11y-' + theme + '-min';
markdownCode.textContent = 'sample';
codeBlock.appendChild(markdownCode);
markdown.appendChild(codeBlock);
return JSON.stringify({
surfaces: [
await readSurface(
'text visualizer',
dialog,
() => dialog.shadowRoot?.querySelector('[part~="control"]'),
visualizerLine),
await readSurface('rendered markdown', markdown, () => codeBlock, markdownCode)
]
});
}
""";
public sealed class AccessibilityDashboardServerFixture : DashboardServerFixture
{
// A small but representative resource set so the resource grid, filters and pickers render
// real content for the scan rather than an empty state.
protected override IReadOnlyList<ResourceViewModel> Resources =>
[
ModelTestHelpers.CreateResource(
resourceName: "frontend",
resourceType: KnownResourceTypes.Project,
state: KnownResourceState.Running),
ModelTestHelpers.CreateResource(
resourceName: "cache",
resourceType: KnownResourceTypes.Container,
state: KnownResourceState.Running),
];
}
}