Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,42 @@ jobs:
-r win-x64
--verbosity normal
--results-directory TestResults\TrayUI
--logger trx;LogFileName=OpenClaw.Tray.UITests.trx"
--logger trx;LogFileName=OpenClaw.Tray.UITests.trx
--filter Category!=Accessibility"

# Accessibility Insights CI pass: runs Axe.Windows scans against each app page.
# Uses continue-on-error so violations surface as a visible warning without
# blocking the build while the team remediates. Remove continue-on-error once
# all violations are resolved (see PR #921 for the initial fixes).
- name: Run Accessibility Tests (Axe.Windows)
id: a11y
continue-on-error: true
run: >
dotnet test tests/OpenClaw.Tray.UITests
--no-build
-c Debug
-r win-x64
--verbosity normal
--results-directory TestResults\Accessibility
--logger "trx;LogFileName=Accessibility.trx"
--filter Category=Accessibility

- name: Accessibility test summary
if: always() && steps.a11y.outcome != 'skipped'
shell: pwsh
run: |
if ("${{ steps.a11y.outcome }}" -eq "failure") {
"### :warning: Accessibility violations detected" >> $env:GITHUB_STEP_SUMMARY
"" >> $env:GITHUB_STEP_SUMMARY
"Axe.Windows scans found WCAG violations in one or more pages." >> $env:GITHUB_STEP_SUMMARY
"See the `Accessibility.trx` artifact for details." >> $env:GITHUB_STEP_SUMMARY
"" >> $env:GITHUB_STEP_SUMMARY
"Fixes for known violations: [PR #921](https://github.com/${{ github.repository }}/pull/921)" >> $env:GITHUB_STEP_SUMMARY
} else {
"### :white_check_mark: Accessibility scans passed" >> $env:GITHUB_STEP_SUMMARY
"" >> $env:GITHUB_STEP_SUMMARY
"All pages passed Axe.Windows accessibility validation." >> $env:GITHUB_STEP_SUMMARY
}

- name: Upload Test Results
if: always()
Expand Down
129 changes: 129 additions & 0 deletions tests/OpenClaw.Tray.UITests/AccessibilityScanTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Axe.Windows.Core.Enums;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Xunit;

namespace OpenClaw.Tray.UITests;

/// <summary>
/// Data-driven accessibility tests that scan each page in the OpenClaw app for
/// WCAG violations using Axe.Windows. Modeled after WinUI-Gallery's
/// AxeScanAllTests pattern.
///
/// Each page is instantiated in the <see cref="UIThreadFixture"/>'s hidden window
/// and scanned via the UIA tree. New pages added to the app should be registered
/// in <see cref="PageTestData"/> to be automatically included in CI scans.
///
/// See PR #921 for fixes to the low-hanging accessibility violations discovered
/// by these tests.
/// </summary>
[Collection(UICollection.Name)]
public sealed class AccessibilityScanTests
{
private readonly UIThreadFixture _ui;

public AccessibilityScanTests(UIThreadFixture ui)
{
_ui = ui;
}

/// <summary>
/// Pages that are completely excluded from scanning because they crash Axe
/// or require infrastructure unavailable in CI (e.g. WebView2, live connections).
/// </summary>
private static readonly HashSet<string> ExcludedPages =
[
// ChatPage hosts CefSharp/WebView2 which causes Axe to traverse the
// Chromium UIA tree and throw NullReferenceException (same root cause as
// WinUI-Gallery's WebView2 exclusion: axe-windows/issues/662).
"ChatPage",
];

/// <summary>
/// Per-page rule exclusions for known issues specific to certain pages.
/// Add entries here when a page has violations caused by WinUI framework
/// limitations or third-party controls that cannot be fixed in app code.
/// Document the reason with a link to the upstream issue.
/// </summary>
private static readonly Dictionary<string, RuleId[]> PageRuleExclusions = new()
{
// ConnectionPage: gateway row cards use dynamic binding for accessible name;
// when no gateways are configured the template has empty Name.
// Fixed in PR #921; keep exclusion in case tests run without that PR.
["ConnectionPage"] = [RuleId.NameNotNull],
};

/// <summary>
/// Enumerates all app pages for data-driven testing. Each entry is
/// [pageName, pageType]. New pages are automatically picked up when added here.
/// </summary>
public static IEnumerable<object[]> PageTestData()
{
var pages = new (string Name, Type Type)[]
{
("AgentEventsPage", typeof(OpenClawTray.Pages.AgentEventsPage)),
("BindingsPage", typeof(OpenClawTray.Pages.BindingsPage)),
("ChannelsPage", typeof(OpenClawTray.Pages.ChannelsPage)),
("ConfigPage", typeof(OpenClawTray.Pages.ConfigPage)),
("ConnectionPage", typeof(OpenClawTray.Pages.ConnectionPage)),
("CronPage", typeof(OpenClawTray.Pages.CronPage)),
("DebugPage", typeof(OpenClawTray.Pages.DebugPage)),
("InstancesPage", typeof(OpenClawTray.Pages.InstancesPage)),
("NotificationsPage", typeof(OpenClawTray.Pages.NotificationsPage)),
("PermissionsPage", typeof(OpenClawTray.Pages.PermissionsPage)),
("SandboxPage", typeof(OpenClawTray.Pages.SandboxPage)),
("SessionsPage", typeof(OpenClawTray.Pages.SessionsPage)),
("SettingsPage", typeof(OpenClawTray.Pages.SettingsPage)),
("SkillsPage", typeof(OpenClawTray.Pages.SkillsPage)),
("UsagePage", typeof(OpenClawTray.Pages.UsagePage)),
("VoiceSettingsPage", typeof(OpenClawTray.Pages.VoiceSettingsPage)),
("WorkspacePage", typeof(OpenClawTray.Pages.WorkspacePage)),
};

foreach (var (name, type) in pages)
{
if (!ExcludedPages.Contains(name))
yield return [name, type];
}
}

/// <summary>
/// Instantiates each page in the test window and scans for accessibility
/// violations using Axe.Windows. Failures include the rule ID, element
/// control type, name, and automation ID for actionability.
/// </summary>
[Theory]
[Trait("Category", "Accessibility")]
[MemberData(nameof(PageTestData))]
public async Task Page_PassesAccessibilityScan(string pageName, Type pageType)
{
await _ui.ResetContainerAsync();

await _ui.RunOnUIAsync(() =>
{
// Initialize Axe scanner on first use (attaches to current process)
AxeHelper.Initialize();

// Instantiate the page and host it in the test container
var page = (UIElement)Activator.CreateInstance(pageType)!;

_ui.Container.Children.Clear();
_ui.Container.Children.Add(page);

// Force layout so the UIA tree is fully populated
_ui.Container.UpdateLayout();
});

// Allow any async UI initialization (bindings, loaded events) to settle
await Task.Delay(500);

await _ui.RunOnUIAsync(() =>
{
PageRuleExclusions.TryGetValue(pageName, out var ruleExclusions);
AxeHelper.AssertNoAccessibilityErrors(ruleExclusions, pageName);
});
}
}
105 changes: 105 additions & 0 deletions tests/OpenClaw.Tray.UITests/AxeHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Axe.Windows.Automation;
using Axe.Windows.Core.Enums;
using Xunit;

namespace OpenClaw.Tray.UITests;

/// <summary>
/// Wraps Axe.Windows scanner to validate the live UI Automation tree for
/// accessibility violations. Modeled after the WinUI-Gallery AxeHelper pattern.
///
/// The scanner attaches to the current process (which hosts the WinUI3 test window
/// via <see cref="UIThreadFixture"/>) and inspects the UIA tree for WCAG violations.
/// </summary>
public static class AxeHelper
{
private static IScanner? _scanner;
private static readonly object _lock = new();

/// <summary>
/// Rules excluded globally due to known WinUI framework bugs that are not
/// fixable in application code. These mirror the WinUI-Gallery exclusions.
/// </summary>
private static readonly HashSet<RuleId> GloballyExcludedRules =
[
// WinUI framework generates non-informative names for some built-in controls
RuleId.NameIsInformative,
// Framework includes control type in auto-generated accessible names
RuleId.NameExcludesControlType,
// Same as above, localized variant
RuleId.NameExcludesLocalizedControlType,
// WinUI framework repeats sibling names in some control patterns
RuleId.SiblingUniqueAndFocusable,
];

/// <summary>
/// Initialize the Axe.Windows scanner for the current process.
/// Thread-safe; subsequent calls are no-ops.
/// </summary>
public static void Initialize()
{
if (_scanner != null) return;

lock (_lock)
{
if (_scanner != null) return;

var processId = Environment.ProcessId;
var config = Config.Builder.ForProcessId(processId).Build();
_scanner = ScannerFactory.CreateScanner(config);
}
}

/// <summary>
/// Scan the current window's UIA tree and assert no accessibility violations exist.
/// </summary>
/// <param name="pageRuleExclusions">
/// Optional per-page rule exclusions for known issues specific to a page.
/// </param>
/// <param name="context">
/// Optional context string (e.g. page name) included in failure messages.
/// </param>
public static void AssertNoAccessibilityErrors(
IEnumerable<RuleId>? pageRuleExclusions = null,
string? context = null)
{
if (_scanner == null)
throw new InvalidOperationException(
"AxeHelper.Initialize() must be called before scanning.");

var excludedRules = new HashSet<RuleId>(GloballyExcludedRules);
if (pageRuleExclusions != null)
excludedRules.UnionWith(pageRuleExclusions);

var scanOutput = _scanner.Scan(null);

var errors = scanOutput.WindowScanOutputs
.SelectMany(output => output.Errors)
.Where(error => !excludedRules.Contains(error.Rule.ID))
.ToList();

if (errors.Count == 0) return;

var errorMessages = errors.Select(error =>
{
var controlType = error.Element.Properties.TryGetValue("ControlType", out var ct)
? ct : "Unknown";
var name = error.Element.Properties.TryGetValue("Name", out var n)
? n : "(no name)";
var automationId = error.Element.Properties.TryGetValue("AutomationId", out var aid)
? aid : "(no id)";
return $" [{error.Rule.ID}] Element '{controlType}' " +
$"(Name='{name}', AutomationId='{automationId}') " +
$"violated rule: {error.Rule.Description}";
});

var header = string.IsNullOrEmpty(context)
? $"Accessibility scan found {errors.Count} violation(s):"
: $"Accessibility scan of '{context}' found {errors.Count} violation(s):";

Assert.Fail($"{header}\r\n{string.Join("\r\n", errorMessages)}");
}
}
8 changes: 6 additions & 2 deletions tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@
<!-- System.Drawing.Common 4.7.0 advisory comes in transitively via the
Tray.WinUI ProjectReference. The main project warns; we don't want it
to escalate to an error here just because tests/Directory.Build.props
sets TreatWarningsAsErrors=true. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1904</WarningsNotAsErrors>
sets TreatWarningsAsErrors=true.
NU1903 also fires for System.IO.Packaging 8.0.0 from Axe.Windows
(GHSA-f32c-w444-8ppv, GHSA-qj66-m88j-hmgj); the scanner runs in-process
during tests only — not shipped — so the advisory is not actionable. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1904;NU1903</WarningsNotAsErrors>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Axe.Windows" Version="2.4.1" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(MicrosoftWindowsAppSDKVersion)" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="$(MicrosoftWindowsSdkBuildToolsVersion)" />
</ItemGroup>
Expand Down
Loading