From fa0e884de8fd5eb2436a9b0a874754be6fb64adc Mon Sep 17 00:00:00 2001 From: Gordon Lam Date: Mon, 6 Jul 2026 10:59:39 +0800 Subject: [PATCH] ui audit: add per-view accessibility audit command Adds a new `winapp ui audit` command that evaluates the currently visible app view for accessibility and contrast issues across names, keyboard, screen-reader-readiness, contrast, and roles, with `--area` and `--level` controls. The command description now explicitly states that it audits one view at a time and should be paired with other UI automation to move through additional pages/tabs/states. Also updates the generated CLI schema and UI automation skill/docs to surface the new command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/skills/winapp-ui-automation/SKILL.md | 42 ++ .../skills/winapp-cli/ui-automation/SKILL.md | 42 ++ .gitignore | 5 +- docs/cli-schema.json | 133 +++++ .../skills/winapp-cli/ui-automation.md | 21 + docs/usage.md | 19 + scripts/generate-llm-docs.ps1 | 2 +- .../WinApp.Cli.Tests/FakeUiServices.cs | 15 + .../WinApp.Cli.Tests/UiAuditEngineTests.cs | 533 ++++++++++++++++++ .../UiAuditOrchestratorTests.cs | 200 +++++++ .../WinApp.Cli.Tests/UiCommandTests.Audit.cs | 291 ++++++++++ .../WinApp.Cli.Tests/UiSessionServiceTests.cs | 1 + .../WinApp.Cli/Commands/UiAuditCommand.cs | 308 ++++++++++ .../WinApp.Cli/Commands/UiCommand.cs | 4 +- .../WinApp.Cli/Helpers/CliSchema.cs | 1 + .../WinApp.Cli/Helpers/ContrastAnalyzer.cs | 196 +++++++ .../Helpers/HostBuilderExtensions.cs | 12 +- .../WinApp.Cli/Helpers/UiAudit/AreaEngines.cs | 121 ++++ .../WinApp.Cli/Helpers/UiAudit/AuditArea.cs | 89 +++ .../Helpers/UiAudit/AuditProfile.cs | 29 + .../Helpers/UiAudit/IUiAuditAreaEngine.cs | 26 + .../Helpers/UiAudit/UiAuditContext.cs | 41 ++ .../Helpers/UiAudit/UiAuditOrchestrator.cs | 91 +++ .../WinApp.Cli/Helpers/UiAuditEngine.cs | 396 +++++++++++++ .../WinApp.Cli/Helpers/UiJsonContext.cs | 49 +- src/winapp-CLI/WinApp.Cli/Models/UiElement.cs | 6 + .../Services/IUiAutomationService.cs | 7 + .../UiAutomationService.Screenshot.cs | 39 +- .../Services/UiAutomationService.cs | 13 + 29 files changed, 2714 insertions(+), 18 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiAuditEngineTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiAuditOrchestratorTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Audit.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/UiAuditCommand.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/ContrastAnalyzer.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AreaEngines.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditArea.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditProfile.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/IUiAuditAreaEngine.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditContext.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditOrchestrator.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiAuditEngine.cs diff --git a/.claude/skills/winapp-ui-automation/SKILL.md b/.claude/skills/winapp-ui-automation/SKILL.md index e5370488..d7a8b63a 100644 --- a/.claude/skills/winapp-ui-automation/SKILL.md +++ b/.claude/skills/winapp-ui-automation/SKILL.md @@ -200,6 +200,27 @@ winapp ui wait-for btn-submit-a1b2 -a myapp --timeout 5000 winapp ui wait-for itm-status-c3d4 -a myapp --value "Complete" --timeout 5000 ``` +### Audit accessibility & contrast +```powershell +# Audit the whole window (all areas, basic level) — exits non-zero if any FAIL is found (CI gate) +winapp ui audit -a myapp + +# Machine-readable report ({ summary, issues }); write it to a file too +winapp ui audit -a myapp --json -o audit.json + +# Scope to specific areas (repeatable). Areas: names, keyboard, screen-reader, contrast, roles +winapp ui audit -a myapp --area names --area keyboard + +# Go deeper: 'thorough' adds heuristic rules (e.g. tab-order coherence) and applies WCAG AAA contrast +winapp ui audit -a myapp --level thorough + +# Contrast only, at the default basic level (WCAG AA thresholds) +winapp ui audit -a myapp --area contrast +``` +- `--area` selects one or more audit areas (default: all). Contrast requires a window pixel capture; it is measured only when the `contrast` area is selected. +- `--level basic` (default) runs fast essential rules with WCAG **AA** contrast thresholds (normal 4.5, large 3.0); `--level thorough` adds heuristic/deeper rules (e.g. keyboard tab-order) and applies WCAG **AAA** contrast thresholds (normal 7.0, large 4.5). +- Non-client chrome (title-bar caption buttons, scrollbar parts) is suppressed, and the same defect surfaced by multiple areas is de-duplicated, so counts aren't inflated. Elements on a different window/HWND than the captured one are reported as "not measured" for contrast rather than mis-scored. + ## Tips - Use `--interactive` with `inspect` as your first command — it shows only what you can click - Chain commands with `;` to reduce round-trips (see note below on why not `&&`) @@ -596,3 +617,24 @@ Show the element that currently has keyboard focus in the target app. | `--app` | Target app (process name, window title, or PID). Lists windows if ambiguous. | (none) | | `--json` | Format output as JSON | (none) | | `--window` | Target window by HWND (stable handle from list output). Takes precedence over --app. | (none) | + +### `winapp ui audit` + +Audit the currently visible view of a running app for accessibility and contrast issues. Walks the element tree and evaluates modular audit areas (names, keyboard, screen-reader, contrast, roles) at a chosen level (basic/thorough). Audits one view at a time — it does not navigate; drive the other ui commands (invoke, send-keys) to move through other pages/tabs/states and audit each. Exits non-zero when any fail-severity issue is found, so it can gate CI. + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | No | Semantic slug (e.g., btn-minimize-d1a0) or text to search by name/automationId | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--app` | Target app (process name, window title, or PID). Lists windows if ambiguous. | (none) | +| `--area` | Accessibility area(s) to audit (repeatable). Allowed: names, keyboard, screen-reader, contrast, roles, all. Default: all. | (none) | +| `--json` | Format output as JSON | (none) | +| `--level` | Audit depth: basic (essential rules + WCAG AA contrast thresholds) or thorough (deeper rules + WCAG AAA contrast thresholds). Default: basic. | `basic` | +| `--output` | Save output to file path (e.g., screenshot) | (none) | +| `--window` | Target window by HWND (stable handle from list output). Takes precedence over --app. | (none) | diff --git a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md index e5370488..d7a8b63a 100644 --- a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md +++ b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md @@ -200,6 +200,27 @@ winapp ui wait-for btn-submit-a1b2 -a myapp --timeout 5000 winapp ui wait-for itm-status-c3d4 -a myapp --value "Complete" --timeout 5000 ``` +### Audit accessibility & contrast +```powershell +# Audit the whole window (all areas, basic level) — exits non-zero if any FAIL is found (CI gate) +winapp ui audit -a myapp + +# Machine-readable report ({ summary, issues }); write it to a file too +winapp ui audit -a myapp --json -o audit.json + +# Scope to specific areas (repeatable). Areas: names, keyboard, screen-reader, contrast, roles +winapp ui audit -a myapp --area names --area keyboard + +# Go deeper: 'thorough' adds heuristic rules (e.g. tab-order coherence) and applies WCAG AAA contrast +winapp ui audit -a myapp --level thorough + +# Contrast only, at the default basic level (WCAG AA thresholds) +winapp ui audit -a myapp --area contrast +``` +- `--area` selects one or more audit areas (default: all). Contrast requires a window pixel capture; it is measured only when the `contrast` area is selected. +- `--level basic` (default) runs fast essential rules with WCAG **AA** contrast thresholds (normal 4.5, large 3.0); `--level thorough` adds heuristic/deeper rules (e.g. keyboard tab-order) and applies WCAG **AAA** contrast thresholds (normal 7.0, large 4.5). +- Non-client chrome (title-bar caption buttons, scrollbar parts) is suppressed, and the same defect surfaced by multiple areas is de-duplicated, so counts aren't inflated. Elements on a different window/HWND than the captured one are reported as "not measured" for contrast rather than mis-scored. + ## Tips - Use `--interactive` with `inspect` as your first command — it shows only what you can click - Chain commands with `;` to reduce round-trips (see note below on why not `&&`) @@ -596,3 +617,24 @@ Show the element that currently has keyboard focus in the target app. | `--app` | Target app (process name, window title, or PID). Lists windows if ambiguous. | (none) | | `--json` | Format output as JSON | (none) | | `--window` | Target window by HWND (stable handle from list output). Takes precedence over --app. | (none) | + +### `winapp ui audit` + +Audit the currently visible view of a running app for accessibility and contrast issues. Walks the element tree and evaluates modular audit areas (names, keyboard, screen-reader, contrast, roles) at a chosen level (basic/thorough). Audits one view at a time — it does not navigate; drive the other ui commands (invoke, send-keys) to move through other pages/tabs/states and audit each. Exits non-zero when any fail-severity issue is found, so it can gate CI. + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | No | Semantic slug (e.g., btn-minimize-d1a0) or text to search by name/automationId | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--app` | Target app (process name, window title, or PID). Lists windows if ambiguous. | (none) | +| `--area` | Accessibility area(s) to audit (repeatable). Allowed: names, keyboard, screen-reader, contrast, roles, all. Default: all. | (none) | +| `--json` | Format output as JSON | (none) | +| `--level` | Audit depth: basic (essential rules + WCAG AA contrast thresholds) or thorough (deeper rules + WCAG AAA contrast thresholds). Default: basic. | `basic` | +| `--output` | Save output to file path (e.g., screenshot) | (none) | +| `--window` | Target window by HWND (stable handle from list output). Takes precedence over --app. | (none) | diff --git a/.gitignore b/.gitignore index 99670e73..bb895505 100644 --- a/.gitignore +++ b/.gitignore @@ -225,4 +225,7 @@ devcert.pfx /src/winapp-NuGet/tools # Do not ignore the targets and props files in the build folder, which are needed for consuming the NuGet package -!/src/winapp-NuGet/build/ \ No newline at end of file +!/src/winapp-NuGet/build/ +# Local scratch / validation working dirs (PR5 UI audit) — never commit +/scratch/ +/validation/ diff --git a/docs/cli-schema.json b/docs/cli-schema.json index f18bc2c1..d9c35928 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -1678,6 +1678,139 @@ "description": "Inspect and interact with any running Windows app using UI Automation (UIA). Works with WPF, WinForms, Win32, Electron, and WinUI 3 apps.", "hidden": false, "subcommands": { + "audit": { + "description": "Audit the currently visible view of a running app for accessibility and contrast issues. Walks the element tree and evaluates modular audit areas (names, keyboard, screen-reader, contrast, roles) at a chosen level (basic/thorough). Audits one view at a time — it does not navigate; drive the other ui commands (invoke, send-keys) to move through other pages/tabs/states and audit each. Exits non-zero when any fail-severity issue is found, so it can gate CI.", + "hidden": false, + "arguments": { + "selector": { + "description": "Semantic slug (e.g., btn-minimize-d1a0) or text to search by name/automationId", + "order": 0, + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + } + } + }, + "options": { + "--app": { + "description": "Target app (process name, window title, or PID). Lists windows if ambiguous.", + "hidden": false, + "aliases": [ + "-a" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--area": { + "description": "Accessibility area(s) to audit (repeatable). Allowed: names, keyboard, screen-reader, contrast, roles, all. Default: all.", + "hidden": false, + "valueType": "System.String[]", + "hasDefaultValue": false, + "arity": { + "minimum": 0 + }, + "required": false, + "recursive": false + }, + "--json": { + "description": "Format output as JSON", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--level": { + "description": "Audit depth: basic (essential rules + WCAG AA contrast thresholds) or thorough (deeper rules + WCAG AAA contrast thresholds). Default: basic.", + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": true, + "defaultValue": "basic", + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--output": { + "description": "Save output to file path (e.g., screenshot)", + "hidden": false, + "aliases": [ + "-o" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--window": { + "description": "Target window by HWND (stable handle from list output). Takes precedence over --app.", + "hidden": false, + "aliases": [ + "-w" + ], + "valueType": "System.Nullable", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + }, "click": { "description": "Click an element by slug or text search using mouse simulation. Works on elements that don't support InvokePattern (e.g., column headers, list items). Use --double for double-click, --right for right-click.", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/ui-automation.md b/docs/fragments/skills/winapp-cli/ui-automation.md index c5824aa8..81669ca1 100644 --- a/docs/fragments/skills/winapp-cli/ui-automation.md +++ b/docs/fragments/skills/winapp-cli/ui-automation.md @@ -195,6 +195,27 @@ winapp ui wait-for btn-submit-a1b2 -a myapp --timeout 5000 winapp ui wait-for itm-status-c3d4 -a myapp --value "Complete" --timeout 5000 ``` +### Audit accessibility & contrast +```powershell +# Audit the whole window (all areas, basic level) — exits non-zero if any FAIL is found (CI gate) +winapp ui audit -a myapp + +# Machine-readable report ({ summary, issues }); write it to a file too +winapp ui audit -a myapp --json -o audit.json + +# Scope to specific areas (repeatable). Areas: names, keyboard, screen-reader, contrast, roles +winapp ui audit -a myapp --area names --area keyboard + +# Go deeper: 'thorough' adds heuristic rules (e.g. tab-order coherence) and applies WCAG AAA contrast +winapp ui audit -a myapp --level thorough + +# Contrast only, at the default basic level (WCAG AA thresholds) +winapp ui audit -a myapp --area contrast +``` +- `--area` selects one or more audit areas (default: all). Contrast requires a window pixel capture; it is measured only when the `contrast` area is selected. +- `--level basic` (default) runs fast essential rules with WCAG **AA** contrast thresholds (normal 4.5, large 3.0); `--level thorough` adds heuristic/deeper rules (e.g. keyboard tab-order) and applies WCAG **AAA** contrast thresholds (normal 7.0, large 4.5). +- Non-client chrome (title-bar caption buttons, scrollbar parts) is suppressed, and the same defect surfaced by multiple areas is de-duplicated, so counts aren't inflated. Elements on a different window/HWND than the captured one are reported as "not measured" for contrast rather than mis-scored. + ## Tips - Use `--interactive` with `inspect` as your first command — it shows only what you can click - Chain commands with `;` to reduce round-trips (see note below on why not `&&`) diff --git a/docs/usage.md b/docs/usage.md index 5ce162ba..417034e6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1091,9 +1091,28 @@ winapp ui [command] [options] - `wait-for` - Wait for element state - `list-windows` - List all windows for an app - `get-focused` - Report the currently focused element +- `audit` - Audit the UI for accessibility and contrast issues (names, keyboard, screen-reader, contrast, roles); exits non-zero on any failure so it can gate CI **Options:** - `-a, --app ` - Target app (name, title, or PID) - `-w, --window ` - Target window by HWND (stable) +**`ui audit` options:** +- `--area ` - Accessibility area(s) to audit (repeatable). Allowed: `names`, `keyboard`, `screen-reader`, `contrast`, `roles`, `all`. Default: all. +- `--level ` - Audit depth: `basic` (essential rules + WCAG **AA** contrast thresholds: normal 4.5, large 3.0) or `thorough` (deeper rules such as keyboard tab-order + WCAG **AAA** contrast thresholds: normal 7.0, large 4.5). Default: `basic`. +- `-o, --output ` - Write the report (text, or JSON with `--json`) to a file. + +```powershell +# Gate CI on accessibility failures (non-zero exit when any FAIL is found) +winapp ui audit -a myapp + +# Contrast only, machine-readable report to a file +winapp ui audit -a myapp --area contrast --json -o audit.json + +# Deeper sweep across all areas (adds tab-order, applies AAA contrast thresholds) +winapp ui audit -a myapp --level thorough +``` + +Contrast is measured only when the `contrast` area is selected; it captures the target window's pixels and samples each text element's bounds. Elements belonging to a different window/HWND than the captured one are reported as "not measured" rather than sampled against the wrong pixels. Non-client chrome (title-bar caption buttons, scrollbar parts) is suppressed, and a defect surfaced by multiple areas is de-duplicated so counts aren't inflated. + For full documentation, see [docs/ui-automation.md](ui-automation.md). diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index ff2735d4..d12af32c 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -104,7 +104,7 @@ $SkillCommandMap = @{ "manifest" = @("manifest generate", "manifest update-assets", "manifest add-alias") "troubleshoot" = @("get-winapp-path", "tool", "store") "frameworks" = @() # No auto-generated command sections — links to guides - "ui-automation" = @("ui status", "ui inspect", "ui search", "ui get-property", "ui get-value", "ui screenshot", "ui invoke", "ui click", "ui drag", "ui hover", "ui send-keys", "ui set-value", "ui focus", "ui scroll-into-view", "ui scroll", "ui wait-for", "ui list-windows", "ui get-focused") + "ui-automation" = @("ui status", "ui inspect", "ui search", "ui get-property", "ui get-value", "ui screenshot", "ui invoke", "ui click", "ui drag", "ui hover", "ui send-keys", "ui set-value", "ui focus", "ui scroll-into-view", "ui scroll", "ui wait-for", "ui list-windows", "ui get-focused", "ui audit") } # Validate that all CLI commands are covered by at least one skill diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs index 9be38fd0..c3a7ca64 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs @@ -37,6 +37,12 @@ internal class FakeUiAutomationService : IUiAutomationService public Dictionary PropertiesResult { get; set; } = []; public string InvokeResult { get; set; } = "InvokePattern"; public (byte[] Pixels, int Width, int Height) ScreenshotResult { get; set; } = (new byte[4], 1, 1); + + /// Configurable full-window capture returned by (used by the audit's contrast checks). + public (byte[] Pixels, int Width, int Height, int OriginX, int OriginY) WindowCaptureResult { get; set; } = (new byte[4], 1, 1, 0, 0); + + /// When set, throws to simulate an unavailable capture. + public Exception? WindowCaptureException { get; set; } public List<(nint Hwnd, int Pid, string Title)> WindowsByTitleResult { get; set; } = []; public List<(nint Hwnd, int Pid, string Title)> WindowsByPidResult { get; set; } = []; @@ -88,6 +94,15 @@ public Task SearchAsync(UiSessionInfo session, SelectorExpression s public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) => Task.FromResult(ScreenshotResult); + public Task<(byte[] Pixels, int Width, int Height, int OriginX, int OriginY)> CaptureWindowAsync(UiSessionInfo session, CancellationToken ct) + { + if (WindowCaptureException is not null) + { + throw WindowCaptureException; + } + return Task.FromResult(WindowCaptureResult); + } + public Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => Task.FromResult(InvokeResult); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiAuditEngineTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiAuditEngineTests.cs new file mode 100644 index 00000000..4f7d4269 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiAuditEngineTests.cs @@ -0,0 +1,533 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Helpers; +using WinApp.Cli.Helpers.UiAudit; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class UiAuditEngineTests +{ + private static UiAuditEngine.Options Opts(params string[] checks) => new() + { + Checks = checks.Length == 0 + ? new HashSet(UiAuditEngine.AllChecks, StringComparer.OrdinalIgnoreCase) + : new HashSet(checks, StringComparer.OrdinalIgnoreCase), + Profile = AuditProfile.Basic, + NormalContrast = 4.5, + LargeContrast = 3.0, + WcagLevel = "AA", + }; + + [TestMethod] + public void Names_InteractiveElementMissingName_ProducesFailure() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckNames)); + + Assert.AreEqual(1, result.Summary.Fail); + var issue = result.Issues.Single(); + Assert.AreEqual(UiAuditEngine.CheckNames, issue.RuleId); + Assert.AreEqual(UiAuditEngine.SeverityFail, issue.Severity); + Assert.AreEqual("btn-x", issue.Selector); + } + + [TestMethod] + public void Names_InteractiveElementWithName_Passes() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "Save", IsEnabled = true, IsKeyboardFocusable = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckNames)); + + Assert.AreEqual(0, result.Issues.Length); + Assert.AreEqual(1, result.Summary.Pass); + } + + [TestMethod] + public void Names_OffscreenElement_IsSkipped() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, IsOffscreen = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckNames)); + + Assert.AreEqual(0, result.Issues.Length); + } + + [TestMethod] + public void Keyboard_InteractiveNotFocusable_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "Go", IsEnabled = true, IsKeyboardFocusable = false }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckKeyboard)); + + Assert.AreEqual(1, result.Summary.Warn); + Assert.AreEqual(UiAuditEngine.CheckKeyboard, result.Issues.Single().RuleId); + Assert.AreEqual(UiAuditEngine.SeverityWarn, result.Issues.Single().Severity); + } + + [TestMethod] + public void Keyboard_FocusableUnnamed_ProducesFailure() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Edit", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "txt-name" }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckKeyboard)); + + Assert.AreEqual(1, result.Summary.Fail); + Assert.AreEqual(UiAuditEngine.CheckKeyboard, result.Issues.Single().RuleId); + } + + [TestMethod] + public void Keyboard_DisabledButFocusable_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "Save", IsEnabled = false, IsKeyboardFocusable = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckKeyboard)); + + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckKeyboard && i.Severity == UiAuditEngine.SeverityWarn)); + } + + [TestMethod] + public void Roles_ActionableUnknownControlType_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Unknown(50000)", Name = "Widget", IsInvokable = true, IsEnabled = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckRoles)); + + Assert.AreEqual(1, result.Summary.Warn); + Assert.AreEqual(UiAuditEngine.CheckRoles, result.Issues.Single().RuleId); + } + + [TestMethod] + public void Roles_ProperControlType_Passes() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "OK", IsInvokable = true, IsEnabled = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckRoles)); + + Assert.AreEqual(0, result.Issues.Length); + Assert.AreEqual(1, result.Summary.Pass); + } + + [TestMethod] + public void Roles_InvokablePane_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Pane", Name = "Wrapper", IsInvokable = true, IsEnabled = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckRoles)); + + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckRoles && i.Severity == UiAuditEngine.SeverityWarn)); + } + + [TestMethod] + public void Names_NameMatchingAutomationId_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "SaveButton", AutomationId = "SaveButton", IsEnabled = true, IsKeyboardFocusable = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckNames)); + + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckNames && i.Severity == UiAuditEngine.SeverityWarn)); + } + + [TestMethod] + public void ScreenReader_ReachableUnnamed_ProducesFailure() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckScreenReader)); + + Assert.AreEqual(1, result.Summary.Fail); + Assert.AreEqual(UiAuditEngine.CheckScreenReader, result.Issues.Single().RuleId); + } + + [TestMethod] + public void ScreenReader_CustomInvokableRole_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Custom", Name = "Widget", IsEnabled = true, IsInvokable = true, IsKeyboardFocusable = true }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckScreenReader)); + + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckScreenReader && i.Severity == UiAuditEngine.SeverityWarn)); + } + + [TestMethod] + public void Contrast_LowRatioTextElement_ProducesFailure() + { + var text = new UiElement { Id = "e0", Type = "Text", Name = "Hello", Width = 100, Height = 16 }; + var elements = new[] { text }; + + // 3.0 ratio is below the AA normal-text threshold (4.5). + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckContrast), _ => 3.0); + + Assert.AreEqual(1, result.Summary.Fail); + var issue = result.Issues.Single(); + Assert.AreEqual(UiAuditEngine.CheckContrast, issue.RuleId); + Assert.AreEqual(UiAuditEngine.SeverityFail, issue.Severity); + } + + [TestMethod] + public void Contrast_HighRatioTextElement_Passes() + { + var text = new UiElement { Id = "e0", Type = "Text", Name = "Hello", Width = 100, Height = 16 }; + var elements = new[] { text }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckContrast), _ => 21.0); + + Assert.AreEqual(0, result.Issues.Length); + Assert.AreEqual(1, result.Summary.Pass); + } + + [TestMethod] + public void Contrast_LargeText_UsesRelaxedThreshold() + { + // 3.5:1 fails for normal text (< 4.5) but passes for large text (>= 3.0). + var large = new UiElement { Id = "e0", Type = "Text", Name = "Big", Width = 200, Height = 30 }; + var elements = new[] { large }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckContrast), _ => 3.5); + + Assert.AreEqual(0, result.Summary.Fail); + Assert.AreEqual(1, result.Summary.Pass); + } + + [TestMethod] + public void TabOrder_BackwardJump_ProducesWarning() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "First", IsKeyboardFocusable = true, X = 10, Y = 100 }, + new UiElement { Id = "e1", Type = "Button", Name = "Second", IsKeyboardFocusable = true, X = 10, Y = 10 }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckTabOrder)); + + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckTabOrder && i.Severity == UiAuditEngine.SeverityWarn)); + } + + [TestMethod] + public void ChecksFilter_OnlySelectedRulesRun() + { + // Element fails names AND keyboard, but we only enable names. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = false }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckNames)); + + Assert.IsTrue(result.Issues.All(i => i.RuleId == UiAuditEngine.CheckNames)); + Assert.IsFalse(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckKeyboard)); + } + + [TestMethod] + public void Separator_ElementsAreIgnored() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "---", Name = "HWND 1: \"x\" (App, Class)" }, + }; + + var result = UiAuditEngine.Run(elements, Opts()); + + Assert.AreEqual(0, result.Issues.Length); + } + + [TestMethod] + public void Chrome_ScrollBarThumb_NotFlagged() + { + // A Thumb that is interactive but not keyboard-focusable would normally trip keyboard + + // screen-reader; as scrollbar chrome it must be suppressed. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Thumb", Name = null, IsEnabled = true, IsInvokable = true, IsKeyboardFocusable = false, Selector = "thumb-x" }, + }; + + var result = UiAuditEngine.Run(elements, Opts()); + + Assert.AreEqual(0, result.Issues.Length, "scrollbar thumb should not be flagged"); + } + + [TestMethod] + public void Chrome_ScrollBarIncrementButton_NotFlagged() + { + // A scrollbar increment button (system-generated name, generic Button type) that is not + // keyboard-focusable would trip keyboard/screen-reader without name-based chrome suppression. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "Vertical Small Increase", IsEnabled = true, IsInvokable = true, IsKeyboardFocusable = false, Selector = "vsi" }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckKeyboard, UiAuditEngine.CheckScreenReader, UiAuditEngine.CheckNames)); + + Assert.AreEqual(0, result.Issues.Length, "scrollbar increment button should not be flagged"); + } + + [TestMethod] + public void Chrome_TitleBarCaptionButton_NotFlagged() + { + // A caption button under a TitleBar ancestor that is not keyboard-focusable would trip + // keyboard/screen-reader; TitleBar ancestry suppresses it. + var elements = new[] + { + new UiElement + { + Id = "e0", Type = "Button", Name = "Close", IsEnabled = true, IsInvokable = true, + IsKeyboardFocusable = false, Selector = "btn-close", + AncestorPath = ["Window", "TitleBar"], + }, + }; + + var result = UiAuditEngine.Run(elements, Opts()); + + Assert.AreEqual(0, result.Issues.Length, "title-bar caption button should not be flagged"); + } + + [TestMethod] + public void LegitButtonNamedClose_WithoutChromeContext_IsStillFlagged() + { + // A real app control literally named "Close" that is NOT a titlebar/scrollbar part must + // NOT be suppressed: its keyboard warning (interactive but not focusable) must still fire. + var elements = new[] + { + new UiElement + { + Id = "e0", Type = "Button", Name = "Close", IsEnabled = true, IsInvokable = true, + IsKeyboardFocusable = false, Selector = "app-close", + }, + }; + + var result = UiAuditEngine.Run(elements, Opts(UiAuditEngine.CheckKeyboard, UiAuditEngine.CheckScreenReader, UiAuditEngine.CheckNames)); + + Assert.IsTrue(result.Issues.Length > 0, "a non-chrome app button named 'Close' must still be flagged"); + Assert.IsTrue(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckKeyboard), "expected the keyboard not-focusable warning to fire"); + } + + [TestMethod] + public void Chrome_UnnamedTitleBarButton_NotFlaggedForMissingName() + { + // An unnamed non-client button under the title bar must not raise a missing-name failure. + var elements = new[] + { + new UiElement + { + Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, + Selector = "chrome-btn", AncestorPath = ["Window", "TitleBar"], + }, + }; + + var result = UiAuditEngine.Run(elements, Opts()); + + Assert.AreEqual(0, result.Summary.Fail, "unnamed title-bar chrome should not fail names/keyboard/screen-reader"); + Assert.IsFalse(result.Issues.Any(i => i.RuleId == UiAuditEngine.CheckNames)); + } +} + +[TestClass] +public class ContrastAnalyzerTests +{ + private static byte[] SolidBgra(int w, int h, byte r, byte g, byte b) + { + var buf = new byte[w * h * 4]; + for (var i = 0; i < w * h; i++) + { + buf[i * 4 + 0] = b; + buf[i * 4 + 1] = g; + buf[i * 4 + 2] = r; + buf[i * 4 + 3] = 255; + } + return buf; + } + + [TestMethod] + public void BlackTextOnWhite_YieldsHighContrast() + { + const int w = 10, h = 10; + var buf = SolidBgra(w, h, 255, 255, 255); // white + // Paint ~25% of pixels black (text glyphs). + for (var i = 0; i < 25; i++) + { + buf[i * 4 + 0] = 0; + buf[i * 4 + 1] = 0; + buf[i * 4 + 2] = 0; + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + Assert.IsNotNull(ratio); + Assert.IsTrue(ratio > 15.0, $"expected high contrast, got {ratio}"); + } + + [TestMethod] + public void GreyOnWhite_IsBelowAaThreshold() + { + const int w = 10, h = 10; + var buf = SolidBgra(w, h, 255, 255, 255); // white + // Paint ~25% mid-grey (#777) — a classic low-contrast case (~4.48:1). + for (var i = 0; i < 25; i++) + { + buf[i * 4 + 0] = 0x77; + buf[i * 4 + 1] = 0x77; + buf[i * 4 + 2] = 0x77; + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + Assert.IsNotNull(ratio); + Assert.IsTrue(ratio < 4.5, $"expected sub-AA contrast, got {ratio}"); + } + + [TestMethod] + public void SolidRegion_IsNotMeasured_ReturnsNull() + { + // A uniform fill has no glyph cluster — it is "not measured" rather than a fabricated ~1:1, + // which previously produced phantom sub-AA contrast failures on solid text backgrounds. + const int w = 8, h = 8; + var buf = SolidBgra(w, h, 200, 200, 200); + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + Assert.IsNull(ratio, $"expected null (not measured), got {ratio}"); + } + + [TestMethod] + public void SparseSingleGlyphOnWhite_IsNotMeasured_ReturnsNull() + { + // A handful of dark pixels on a large white rect (short text) must NOT collapse to a + // fabricated sub-AA number; the glyph cluster is below the coverage floor -> not measured. + const int w = 40, h = 40; // 1600 opaque px + var buf = SolidBgra(w, h, 255, 255, 255); + for (var i = 0; i < 3; i++) // 3 black pixels, far below the 8px / 0.5% floor + { + buf[i * 4 + 0] = 0; + buf[i * 4 + 1] = 0; + buf[i * 4 + 2] = 0; + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + // Must not be a fabricated sub-AA ratio. Null is the expected "not measured" outcome. + Assert.IsTrue(ratio is null || ratio >= 4.5, $"expected null or high contrast, got {ratio}"); + } + + [TestMethod] + public void MostlyTransparentRect_IsNotMeasured_ReturnsNull() + { + // A rect that is mostly transparent (layered/empty) must not be scored as its raw RGB. + const int w = 10, h = 10; + var buf = SolidBgra(w, h, 0, 0, 0); // black RGB... + for (var i = 0; i < w * h; i++) + { + buf[i * 4 + 3] = 0; // ...but fully transparent + } + // Make a small opaque minority (< 40%). + for (var i = 0; i < 10; i++) + { + buf[i * 4 + 3] = 255; + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + Assert.IsNull(ratio, $"expected null (not measured) for mostly-transparent rect, got {ratio}"); + } + + [TestMethod] + public void FullyTransparentRect_ReturnsNull() + { + const int w = 8, h = 8; + var buf = SolidBgra(w, h, 0, 0, 0); + for (var i = 0; i < w * h; i++) + { + buf[i * 4 + 3] = 0; // all transparent + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + Assert.IsNull(ratio); + } + + [TestMethod] + public void TransparentGlyphPixels_AreIgnored_NotScoredAsBlack() + { + // Grey glyphs (opaque) on white, plus scattered transparent-black pixels that must be + // ignored (not pulled in as black outliers that would skew the ratio). + const int w = 20, h = 20; // 400 px + var buf = SolidBgra(w, h, 255, 255, 255); + // 100 opaque grey glyph pixels (25% coverage). + for (var i = 0; i < 100; i++) + { + buf[i * 4 + 0] = 0x77; + buf[i * 4 + 1] = 0x77; + buf[i * 4 + 2] = 0x77; + } + // 50 transparent pure-black pixels elsewhere — must be skipped by the alpha guard. + for (var i = 300; i < 350; i++) + { + buf[i * 4 + 0] = 0; + buf[i * 4 + 1] = 0; + buf[i * 4 + 2] = 0; + buf[i * 4 + 3] = 0; + } + + var ratio = ContrastAnalyzer.ComputeContrastRatio(buf, w, h, new ContrastAnalyzer.PixelRect(0, 0, w, h)); + + // Should reflect grey-on-white (~4.4:1), NOT black-on-white (~21:1). + Assert.IsNotNull(ratio); + Assert.IsTrue(ratio < 6.0 && ratio > 3.5, $"expected grey-on-white ratio, got {ratio}"); + } + + [TestMethod] + public void DegenerateRect_ReturnsNull() + { + var buf = SolidBgra(4, 4, 0, 0, 0); + Assert.IsNull(ContrastAnalyzer.ComputeContrastRatio(buf, 4, 4, new ContrastAnalyzer.PixelRect(0, 0, 0, 0))); + Assert.IsNull(ContrastAnalyzer.ComputeContrastRatio(buf, 4, 4, new ContrastAnalyzer.PixelRect(10, 10, 2, 2))); + } + + [TestMethod] + public void KnownColors_ProduceExpectedRatio() + { + // Black vs white is exactly 21:1 by WCAG definition. + var black = ContrastAnalyzer.RelativeLuminance(0, 0, 0); + var white = ContrastAnalyzer.RelativeLuminance(255, 255, 255); + var ratio = ContrastAnalyzer.ContrastRatio(black, white); + Assert.AreEqual(21.0, ratio, 0.01); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiAuditOrchestratorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiAuditOrchestratorTests.cs new file mode 100644 index 00000000..c4f5fcf6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiAuditOrchestratorTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Helpers; +using WinApp.Cli.Helpers.UiAudit; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class UiAuditOrchestratorTests +{ + private static UiAuditOrchestrator DefaultOrchestrator() => new( + [ + new NamesAreaEngine(), + new KeyboardAreaEngine(), + new ScreenReaderAreaEngine(), + new ContrastAreaEngine(), + new RolesAreaEngine(), + new EventsAreaEngine(), + ]); + + private static UiAuditContext Context(IReadOnlyList elements, string profile = AuditProfile.Basic, + Func? contrast = null) => new() + { + Elements = elements, + Profile = profile, + NormalContrast = 4.5, + LargeContrast = 3.0, + WcagLevel = "AA", + ContrastProvider = contrast, + }; + + [TestMethod] + public void Resolve_EmptySelection_DefaultsToAllImplemented() + { + var resolved = AuditArea.Resolve([], out var invalid); + Assert.IsNull(invalid); + CollectionAssert.AreEqual(AuditArea.Implemented.ToArray(), resolved!.ToArray()); + } + + [TestMethod] + public void Resolve_AllToken_ExpandsToImplemented() + { + var resolved = AuditArea.Resolve(["all"], out _); + CollectionAssert.AreEqual(AuditArea.Implemented.ToArray(), resolved!.ToArray()); + } + + [TestMethod] + public void Resolve_RepeatedAndDeduped_PreservesCanonicalOrder() + { + // Passed out of order + duplicated; expect canonical Implemented ordering, de-duped. + var resolved = AuditArea.Resolve(["roles", "names", "names"], out var invalid); + Assert.IsNull(invalid); + CollectionAssert.AreEqual(new[] { AuditArea.Names, AuditArea.Roles }, resolved!.ToArray()); + } + + [TestMethod] + public void Resolve_InvalidArea_ReturnsNullAndReportsToken() + { + var resolved = AuditArea.Resolve(["bogus"], out var invalid); + Assert.IsNull(resolved); + Assert.AreEqual("bogus", invalid); + } + + [TestMethod] + public void Profile_Normalize_AcceptsKnownRejectsUnknown() + { + Assert.AreEqual(AuditProfile.Basic, AuditProfile.Normalize(null)); + Assert.AreEqual(AuditProfile.Thorough, AuditProfile.Normalize("THOROUGH")); + Assert.IsNull(AuditProfile.Normalize("deep")); + } + + [TestMethod] + public void KeyboardArea_TabOrder_OnlyRunsInThoroughProfile() + { + // Two focusable elements with a backward jump — a tab-order warning only when tab-order runs. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = "First", IsKeyboardFocusable = true, IsEnabled = true, X = 10, Y = 100 }, + new UiElement { Id = "e1", Type = "Button", Name = "Second", IsKeyboardFocusable = true, IsEnabled = true, X = 10, Y = 10 }, + }; + var orchestrator = DefaultOrchestrator(); + + var basic = orchestrator.Run([AuditArea.Keyboard], Context(elements, AuditProfile.Basic)); + Assert.IsFalse(basic.Issues.Any(i => i.RuleId == UiAuditEngine.CheckTabOrder), + "basic profile should not run the tab-order heuristic"); + + var thorough = orchestrator.Run([AuditArea.Keyboard], Context(elements, AuditProfile.Thorough)); + Assert.IsTrue(thorough.Issues.Any(i => i.RuleId == UiAuditEngine.CheckTabOrder), + "thorough profile should add the tab-order heuristic"); + } + + [TestMethod] + public void Run_MergesFindingsAndSumsSummaryAcrossAreas() + { + // One element that fails names AND (unfocusable+interactive) keyboard. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = false, Selector = "btn" }, + }; + var orchestrator = DefaultOrchestrator(); + + var merged = orchestrator.Run([AuditArea.Names, AuditArea.Keyboard], Context(elements)); + + // names -> fail (no name); keyboard -> warn (interactive, enabled, visible, not focusable). + Assert.AreEqual(1, merged.Summary.Fail); + Assert.AreEqual(1, merged.Summary.Warn); + Assert.IsTrue(merged.Issues.Any(i => i.RuleId == UiAuditEngine.CheckNames)); + Assert.IsTrue(merged.Issues.Any(i => i.RuleId == UiAuditEngine.CheckKeyboard)); + } + + [TestMethod] + public void EventsArea_ProducesNoFindingsUntilDynamicSupportLands() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true }, + }; + var orchestrator = DefaultOrchestrator(); + + var result = orchestrator.Run([AuditArea.Events], Context(elements)); + + Assert.AreEqual(0, result.Issues.Length); + Assert.AreEqual(0, result.Summary.Fail); + Assert.AreEqual(0, result.Summary.Warn); + } + + [TestMethod] + public void Resolve_EventsArea_IsRejectedAsReserved() + { + // events is a reserved no-op extension point: not user-selectable for now. + var resolved = AuditArea.Resolve(["events"], out var invalid); + Assert.IsNull(resolved); + Assert.AreEqual("events", invalid); + CollectionAssert.DoesNotContain(AuditArea.Selectable.ToArray(), AuditArea.Events); + CollectionAssert.DoesNotContain(AuditArea.Implemented.ToArray(), AuditArea.Events); + } + + [TestMethod] + public void Run_DefaultAllAreas_DoesNotTripleCountOneUnnamedElement() + { + // A single unnamed focusable element trips the missing-name defect in names, keyboard, and + // screen-reader. Cross-area de-duplication must collapse it to ONE failure (canonically the + // names finding) rather than inflating the fail count to 3. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + }; + var orchestrator = DefaultOrchestrator(); + + var result = orchestrator.Run(AuditArea.Implemented, Context(elements)); + + Assert.AreEqual(1, result.Summary.Fail, "missing name must not be triple-counted across areas"); + var missingNameFails = result.Issues.Count(i => + i.Severity == UiAuditEngine.SeverityFail && i.Selector == "btn-x"); + Assert.AreEqual(1, missingNameFails); + // The surviving finding is the canonical names one (area order). + Assert.AreEqual(UiAuditEngine.CheckNames, result.Issues.Single(i => i.Selector == "btn-x").RuleId); + } + + [TestMethod] + public void Run_SingleScreenReaderArea_StillEmitsMissingName() + { + // De-duplication must not suppress a defect when only one area runs. + var elements = new[] + { + new UiElement { Id = "e0", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + }; + var orchestrator = DefaultOrchestrator(); + + var result = orchestrator.Run([AuditArea.ScreenReader], Context(elements)); + + Assert.AreEqual(1, result.Summary.Fail); + Assert.AreEqual(UiAuditEngine.CheckScreenReader, result.Issues.Single().RuleId); + } + + [TestMethod] + public void AnyRequiresContrastCapture_TrueOnlyForContrastArea() + { + var orchestrator = DefaultOrchestrator(); + Assert.IsTrue(orchestrator.AnyRequiresContrastCapture([AuditArea.Contrast])); + Assert.IsFalse(orchestrator.AnyRequiresContrastCapture([AuditArea.Names, AuditArea.Roles])); + } + + [TestMethod] + public void ContrastArea_UsesProviderWhenSelected() + { + var elements = new[] + { + new UiElement { Id = "e0", Type = "Text", Name = "Hello", Width = 100, Height = 16 }, + }; + var orchestrator = DefaultOrchestrator(); + + var result = orchestrator.Run([AuditArea.Contrast], Context(elements, contrast: _ => 2.0)); + + Assert.AreEqual(1, result.Summary.Fail); + Assert.AreEqual(UiAuditEngine.CheckContrast, result.Issues.Single().RuleId); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Audit.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Audit.cs new file mode 100644 index 00000000..2691fbe8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Audit.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Tests; + +public partial class UiCommandTests +{ + [TestMethod] + public async Task Audit_WithoutApp_ReturnsError() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + public async Task Audit_MissingName_FailsAndExitsNonZero() + { + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Window", Name = "App", IsEnabled = true }, + new UiElement { Id = "e1", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "names", "--json"]); + + Assert.AreEqual(1, exitCode); + var output = TestAnsiConsole.Output; + StringAssert.Contains(output, "\"ruleId\": \"names\""); + StringAssert.Contains(output, "\"severity\": \"fail\""); + StringAssert.Contains(output, "\"fail\": 1"); + } + + [TestMethod] + public async Task Audit_CleanTree_PassesAndExitsZero() + { + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Window", Name = "App", IsEnabled = true }, + new UiElement { Id = "e1", Type = "Button", Name = "OK", IsEnabled = true, IsKeyboardFocusable = true, IsInvokable = true }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "names", "--area", "keyboard", "--area", "roles", "--json"]); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(TestAnsiConsole.Output, "\"fail\": 0"); + } + + [TestMethod] + public async Task Audit_InvalidLevel_ReturnsError() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--level", "deep", "--json"]); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + public async Task Audit_WritesReportFile() + { + _fakeUia.InspectResult = + [ + new UiElement { Id = "e1", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + ]; + + var reportPath = Path.Combine(_tempDirectory.FullName, "audit.json"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "names", "--json", "-o", reportPath]); + + Assert.AreEqual(1, exitCode); + Assert.IsTrue(File.Exists(reportPath), "expected report file to be written"); + var content = await File.ReadAllTextAsync(reportPath, TestContext.CancellationToken); + StringAssert.Contains(content, "\"ruleId\": \"names\""); + } + + [TestMethod] + public async Task Audit_ContrastCaptureUnavailable_SkipsContrastGracefully() + { + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Text", Name = "Hello", Width = 100, Height = 16 }, + ]; + _fakeUia.WindowCaptureException = new InvalidOperationException("no capture"); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "contrast", "--json"]); + + // No fail-severity issues since contrast could not be measured. + Assert.AreEqual(0, exitCode); + StringAssert.Contains(TestAnsiConsole.Output, "\"fail\": 0"); + } + + [TestMethod] + public async Task Audit_AreaNames_RunsOnlyNamesArea() + { + // Element fails names (no name) AND keyboard (interactive, not focusable), but we scope to + // the names area only, so only a names finding should surface. + _fakeUia.InspectResult = + [ + new UiElement { Id = "e1", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = false, Selector = "btn-x" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "names", "--json"]); + + Assert.AreEqual(1, exitCode); + var output = TestAnsiConsole.Output; + StringAssert.Contains(output, "\"ruleId\": \"names\""); + StringAssert.DoesNotMatch(output, new System.Text.RegularExpressions.Regex("\"ruleId\":\\s*\"keyboard\"")); + } + + [TestMethod] + public async Task Audit_LevelBasic_OmitsTabOrderFromKeyboardArea() + { + // Backward-jumping focusable elements: tab-order warns only under the thorough level. + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Button", Name = "First", IsEnabled = true, IsKeyboardFocusable = true, X = 10, Y = 100 }, + new UiElement { Id = "e1", Type = "Button", Name = "Second", IsEnabled = true, IsKeyboardFocusable = true, X = 10, Y = 10 }, + ]; + + var basicCmd = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(basicCmd, ["-a", "TestApp", "--area", "keyboard", "--level", "basic", "--json"]); + StringAssert.DoesNotMatch(TestAnsiConsole.Output, + new System.Text.RegularExpressions.Regex("\"ruleId\":\\s*\"tab-order\"")); + } + + [TestMethod] + public async Task Audit_LevelThorough_ReportsTabOrder() + { + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Button", Name = "First", IsEnabled = true, IsKeyboardFocusable = true, X = 10, Y = 100 }, + new UiElement { Id = "e1", Type = "Button", Name = "Second", IsEnabled = true, IsKeyboardFocusable = true, X = 10, Y = 10 }, + ]; + + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "keyboard", "--level", "thorough", "--json"]); + StringAssert.Contains(TestAnsiConsole.Output, "\"ruleId\": \"tab-order\""); + } + + [TestMethod] + public async Task Audit_InvalidArea_ReturnsError() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "bogus", "--json"]); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + public async Task Audit_AllAreasBasic_DeDuplicatesMissingNameAcrossAreas() + { + // No --area/--level: defaults to all areas, basic level. A single unnamed focusable + // element trips the SAME missing-name defect in names, keyboard, and screen-reader. Cross- + // area de-duplication collapses it to ONE failure (the canonical names finding) rather than + // triple-counting it. + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Window", Name = "App", IsEnabled = true }, + new UiElement { Id = "e1", Type = "Button", Name = null, IsEnabled = true, IsKeyboardFocusable = true, Selector = "btn-x" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + var output = TestAnsiConsole.Output; + StringAssert.Contains(output, "\"ruleId\": \"names\""); + StringAssert.Contains(output, "\"fail\": 1"); + // The duplicate missing-name findings from keyboard/screen-reader are collapsed away. + StringAssert.DoesNotMatch(output, new System.Text.RegularExpressions.Regex("\"fail\":\\s*3")); + } + + [TestMethod] + public async Task Audit_AreaEvents_ReturnsError() + { + // events is a reserved no-op area, not user-selectable for now. + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "events", "--json"]); + Assert.AreEqual(1, exitCode); + } + + [TestMethod] + public async Task Audit_Contrast_OutOfWindowElement_IsNotMeasured() + { + // The captured buffer belongs to the session's root window (HWND 100). An element on a + // different HWND (a popup, 200) must be marked "not measured" rather than sampled against + // the wrong pixels — so it produces no contrast failure even though the buffer is low + // contrast. The in-window element IS scored (and fails). + _fakeSession.SessionResult = new UiSessionInfo + { + ProcessId = 1234, + ProcessName = "TestApp", + WindowTitle = "Test Window", + WindowHandle = 100, + }; + + // 20x20 grey (#999) glyphs on white → a clear sub-AA (~2.8:1) failure for normal text. + const int w = 20, h = 20; + var buf = new byte[w * h * 4]; + for (var i = 0; i < w * h; i++) + { + byte v = i < 100 ? (byte)0x99 : (byte)0xFF; // 25% grey glyph coverage, rest white + buf[i * 4 + 0] = v; + buf[i * 4 + 1] = v; + buf[i * 4 + 2] = v; + buf[i * 4 + 3] = 255; + } + _fakeUia.WindowCaptureResult = (buf, w, h, 0, 0); + + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Text", Name = "InWin", Width = w, Height = 16, X = 0, Y = 0, WindowHandle = 100, Selector = "in-win" }, + new UiElement { Id = "e1", Type = "Text", Name = "Popup", Width = w, Height = 16, X = 0, Y = 0, WindowHandle = 200, Selector = "popup" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "contrast", "--json"]); + + var output = TestAnsiConsole.Output; + Assert.AreEqual(1, exitCode, "the in-window low-contrast text should fail"); + StringAssert.Contains(output, "\"fail\": 1"); + StringAssert.Contains(output, "\"selector\": \"in-win\""); + // The out-of-window popup element must NOT appear as a contrast finding. + StringAssert.DoesNotMatch(output, new System.Text.RegularExpressions.Regex("\"selector\":\\s*\"popup\"")); + } + + // A 20x20 buffer whose glyph pixels are mid-grey (#696969, ~5.5:1 on white) — above the AA + // normal-text threshold (4.5) but below AAA (7.0). + private static (byte[] Pixels, int W, int H, int OX, int OY) MidGreyOnWhiteCapture() + { + const int w = 20, h = 20; + var buf = new byte[w * h * 4]; + for (var i = 0; i < w * h; i++) + { + byte v = i < 100 ? (byte)0x69 : (byte)0xFF; // 25% grey glyph coverage, rest white + buf[i * 4 + 0] = v; + buf[i * 4 + 1] = v; + buf[i * 4 + 2] = v; + buf[i * 4 + 3] = 255; + } + return (buf, w, h, 0, 0); + } + + [TestMethod] + public async Task Audit_ContrastLevelBasic_MidGreyPassesUnderAa() + { + _fakeSession.SessionResult = new UiSessionInfo + { + ProcessId = 1234, ProcessName = "TestApp", WindowTitle = "Test Window", WindowHandle = 100, + }; + _fakeUia.WindowCaptureResult = MidGreyOnWhiteCapture(); + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Text", Name = "Grey", Width = 20, Height = 16, X = 0, Y = 0, WindowHandle = 100, Selector = "grey" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "contrast", "--json"]); + + Assert.AreEqual(0, exitCode, "mid-grey (~5.5:1) meets the AA 4.5 threshold used by --level basic"); + StringAssert.Contains(TestAnsiConsole.Output, "\"fail\": 0"); + } + + [TestMethod] + public async Task Audit_ContrastLevelThorough_UsesAaaThreshold() + { + _fakeSession.SessionResult = new UiSessionInfo + { + ProcessId = 1234, ProcessName = "TestApp", WindowTitle = "Test Window", WindowHandle = 100, + }; + _fakeUia.WindowCaptureResult = MidGreyOnWhiteCapture(); + _fakeUia.InspectResult = + [ + new UiElement { Id = "e0", Type = "Text", Name = "Grey", Width = 20, Height = 16, X = 0, Y = 0, WindowHandle = 100, Selector = "grey" }, + ]; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--area", "contrast", "--level", "thorough", "--json"]); + + Assert.AreEqual(1, exitCode, "mid-grey (~5.5:1) fails the AAA 7.0 threshold used by --level thorough"); + var output = TestAnsiConsole.Output; + StringAssert.Contains(output, "\"fail\": 1"); + StringAssert.Contains(output, "AAA"); + StringAssert.Contains(output, "7.0:1"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiSessionServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiSessionServiceTests.cs index 01402e5a..b0d3a26c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiSessionServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiSessionServiceTests.cs @@ -73,6 +73,7 @@ private sealed class StubUiAutomation : IUiAutomationService public Task FindSingleElementAsync(UiSessionInfo session, SelectorExpression selector, CancellationToken ct) => Task.FromResult(null); public Task> GetPropertiesAsync(UiSessionInfo session, UiElement element, string? propertyName, CancellationToken ct) => Task.FromResult(new Dictionary()); public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) => Task.FromResult((Array.Empty(), 0, 0)); + public Task<(byte[] Pixels, int Width, int Height, int OriginX, int OriginY)> CaptureWindowAsync(UiSessionInfo session, CancellationToken ct) => Task.FromResult((Array.Empty(), 0, 0, 0, 0)); public Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => Task.FromResult(""); public Task SetValueAsync(UiSessionInfo session, UiElement element, string text, CancellationToken ct) => Task.CompletedTask; public Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => Task.CompletedTask; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiAuditCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiAuditCommand.cs new file mode 100644 index 00000000..b46970ae --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiAuditCommand.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Helpers; +using WinApp.Cli.Helpers.UiAudit; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +internal class UiAuditCommand : Command, IShortDescription +{ + public string ShortDescription => "Audit the current app view for accessibility and contrast issues"; + + public static Option AreaOption { get; } + public static Option LevelOption { get; } + + // Depth deep enough to walk an entire window's element tree. + private const int AuditDepth = 40; + + static UiAuditCommand() + { + AreaOption = new Option("--area") + { + Description = "Accessibility area(s) to audit (repeatable). Allowed: " + + $"{string.Join(", ", AuditArea.Selectable)}. Default: all.", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + + LevelOption = new Option("--level") + { + Description = "Audit depth: basic (essential rules + WCAG AA contrast thresholds) or " + + "thorough (deeper rules + WCAG AAA contrast thresholds). Default: basic.", + DefaultValueFactory = _ => AuditProfile.Basic, + }; + } + + public UiAuditCommand() + : base("audit", "Audit the currently visible view of a running app for accessibility and contrast issues. " + + "Walks the element tree and evaluates modular audit areas (names, keyboard, " + + "screen-reader, contrast, roles) at a chosen level (basic/thorough). " + + "Audits one view at a time — it does not navigate; drive the other ui commands " + + "(invoke, send-keys) to move through other pages/tabs/states and audit each. " + + "Exits non-zero when any fail-severity issue is found, so it can gate CI.") + { + Arguments.Add(SharedUiOptions.SelectorArgument); + Options.Add(SharedUiOptions.AppOption); + Options.Add(SharedUiOptions.WindowOption); + + Options.Add(WinAppRootCommand.JsonOption); + Options.Add(SharedUiOptions.OutputOption); + Options.Add(AreaOption); + Options.Add(LevelOption); + } + + public class Handler( + IUiSessionService sessionService, + IUiAutomationService uiAutomation, + UiAuditOrchestrator orchestrator, + IAnsiConsole ansiConsole, + ILogger logger) : AsynchronousCommandLineAction + { + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + + if (string.IsNullOrWhiteSpace(app) && window is null) + { + UiErrors.MissingApp(logger, json); + return 1; + } + + var output = parseResult.GetValue(SharedUiOptions.OutputOption); + var rawAreas = parseResult.GetValue(AreaOption) ?? []; + + // Resolve the selected areas (WHAT to audit). + var areas = AuditArea.Resolve(rawAreas, out var invalidArea); + if (areas is null) + { + var msg = $"Invalid --area '{invalidArea}'. Allowed values: {string.Join(", ", AuditArea.Selectable)}."; + logger.LogError("{Symbol} {Message}", UiSymbols.Error, msg); + UiJsonError.Emit(json, UiJsonError.CodeInternalError, msg); + return 1; + } + + // Resolve the audit level (HOW DEEP). + var level = AuditProfile.Normalize(parseResult.GetValue(LevelOption)); + if (level is null) + { + var msg = $"Invalid --level '{parseResult.GetValue(LevelOption)}'. Allowed values: {string.Join(", ", AuditProfile.All)}."; + logger.LogError("{Symbol} {Message}", UiSymbols.Error, msg); + UiJsonError.Emit(json, UiJsonError.CodeInternalError, msg); + return 1; + } + + // The level drives the WCAG contrast thresholds: basic => AA (4.5 / 3.0), + // thorough => AAA (7.0 / 4.5). + var thorough = level == AuditProfile.Thorough; + var normalThreshold = thorough ? 7.0 : 4.5; + var largeThreshold = thorough ? 4.5 : 3.0; + var wcagLevel = thorough ? "AAA" : "AA"; + + // Contrast is measured only when a selected area requires a pixel capture. + var needsContrast = orchestrator.AnyRequiresContrastCapture(areas); + + try + { + var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); + var elements = await uiAutomation.InspectAsync(session, selector, AuditDepth, cancellationToken); + + // Build the contrast provider (best-effort): capture the window once, then sample + // each text element's bounding rectangle. If capture fails, contrast is skipped. + Func? contrastProvider = null; + var contrastMeasured = false; + if (needsContrast) + { + var ratios = await TryComputeContrastAsync(session, elements, cancellationToken); + if (ratios is not null) + { + contrastMeasured = true; + contrastProvider = el => ratios.TryGetValue(el, out var r) ? r : null; + } + } + + var context = new UiAuditContext + { + Elements = elements, + Profile = level, + NormalContrast = normalThreshold, + LargeContrast = largeThreshold, + WcagLevel = wcagLevel, + ContrastProvider = contrastProvider, + }; + var result = orchestrator.Run(areas, context); + + var exitCode = result.Summary.Fail > 0 ? 1 : 0; + + if (json) + { + var payload = JsonSerializer.Serialize(result, UiJsonContext.Default.UiAuditResult); + ansiConsole.Profile.Out.Writer.WriteLine(payload); + if (!string.IsNullOrEmpty(output)) + { + await WriteReportFileAsync(output, payload, cancellationToken); + } + } + else + { + var report = BuildHumanReport(result, session, level, areas, needsContrast, contrastMeasured); + ansiConsole.Markup(report.Markup); + if (!string.IsNullOrEmpty(output)) + { + await WriteReportFileAsync(output, report.PlainText, cancellationToken); + ansiConsole.MarkupLine($"[grey]Report written to {Markup.Escape(Path.GetFullPath(output))}[/]"); + } + } + + logger.LogDebug("Audit evaluated {Count} elements: pass={Pass} warn={Warn} fail={Fail}", + elements.Length, result.Summary.Pass, result.Summary.Warn, result.Summary.Fail); + return exitCode; + } + catch (System.Runtime.InteropServices.COMException comEx) + { + logger.LogDebug("COM error: {HResult} {StackTrace}", comEx.HResult, comEx.StackTrace); + UiErrors.StaleElement(logger, json); + return 1; + } + catch (Exception ex) + { + UiErrors.GenericError(logger, ex, json); + return 1; + } + } + + /// + /// Best-effort contrast measurement: capture the target window once and sample each text + /// element's bounding rectangle. Returns null when the window could not be captured. + /// + private async Task?> TryComputeContrastAsync( + UiSessionInfo session, UiElement[] elements, CancellationToken ct) + { + try + { + var (pixels, width, height, originX, originY) = await uiAutomation.CaptureWindowAsync(session, ct); + var ratios = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var el in elements) + { + if (el.Type == "---" || el.Width <= 0 || el.Height <= 0) + { + continue; + } + + // Multi-HWND guard: the captured buffer belongs to the session's root window. + // Elements that belong to a different HWND (e.g. popups / secondary windows) + // must NOT be sampled against it — mark them "not measured" (null) instead of + // reading the wrong pixels. + if (el.WindowHandle is { } elHwnd && elHwnd != 0 && elHwnd != session.WindowHandle) + { + ratios[el] = null; + continue; + } + + var rect = new ContrastAnalyzer.PixelRect( + (int)Math.Round(el.X - originX), + (int)Math.Round(el.Y - originY), + (int)Math.Round(el.Width), + (int)Math.Round(el.Height)); + + // Bounds guard: only sample elements whose rect lies within the captured + // buffer. Anything outside the captured origin+size is a different surface — + // return null rather than clamping onto unrelated pixels. + if (rect.X < 0 || rect.Y < 0 || rect.X + rect.Width > width || rect.Y + rect.Height > height) + { + ratios[el] = null; + continue; + } + + ratios[el] = ContrastAnalyzer.ComputeContrastRatio(pixels, width, height, rect); + } + return ratios; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Contrast capture failed; skipping contrast checks"); + return null; + } + } + + private static async Task WriteReportFileAsync(string output, string content, CancellationToken ct) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(output)); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + await File.WriteAllTextAsync(output, content, ct); + } + + private static (string Markup, string PlainText) BuildHumanReport( + UiAuditResult result, UiSessionInfo session, string level, + IReadOnlyList scope, bool needsContrast, bool contrastMeasured) + { + var markup = new StringBuilder(); + var plain = new StringBuilder(); + + void Line(string markupText, string plainText) + { + markup.AppendLine(markupText); + plain.AppendLine(plainText); + } + + var title = session.WindowTitle ?? session.ProcessName; + Line($"[bold]Accessibility audit:[/] {Markup.Escape(title)} (PID {session.ProcessId})", + $"Accessibility audit: {title} (PID {session.ProcessId})"); + + var scopeText = string.Join(", ", scope); + Line($"[grey]Areas: {scopeText} · Level: {level}[/]", + $"Areas: {scopeText} · Level: {level}"); + + if (needsContrast && !contrastMeasured) + { + Line("[yellow]⚠ Contrast could not be measured (window capture unavailable) — contrast checks were skipped.[/]", + "! Contrast could not be measured (window capture unavailable) — contrast checks were skipped."); + } + + markup.AppendLine(); + plain.AppendLine(); + + if (result.Issues.Length == 0) + { + Line("[green]✓ No accessibility issues found.[/]", "No accessibility issues found."); + } + else + { + foreach (var issue in result.Issues) + { + var isFail = issue.Severity == UiAuditEngine.SeverityFail; + var sevMarkup = isFail ? "[red]FAIL[/]" : "[yellow]WARN[/]"; + var sevPlain = isFail ? "FAIL" : "WARN"; + var sel = string.IsNullOrEmpty(issue.Selector) ? "" : $" [cyan]{Markup.Escape(issue.Selector)}[/]"; + var selPlain = string.IsNullOrEmpty(issue.Selector) ? "" : $" {issue.Selector}"; + Line($"{sevMarkup} [grey]({issue.RuleId})[/]{sel} {Markup.Escape(issue.Message)}", + $"{sevPlain} ({issue.RuleId}){selPlain} {issue.Message}"); + } + } + + markup.AppendLine(); + plain.AppendLine(); + + var s = result.Summary; + Line($"[bold]Summary:[/] [green]{s.Pass} passed[/], [yellow]{s.Warn} warnings[/], [red]{s.Fail} failures[/]", + $"Summary: {s.Pass} passed, {s.Warn} warnings, {s.Fail} failures"); + + return (markup.ToString(), plain.ToString()); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs index 57bdaae9..80096adf 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs @@ -27,7 +27,8 @@ public UiCommand( UiScrollCommand scrollCommand, UiWaitForCommand waitForCommand, UiListWindowsCommand listWindowsCommand, - UiGetFocusedCommand getFocusedCommand) + UiGetFocusedCommand getFocusedCommand, + UiAuditCommand auditCommand) : base("ui", "Inspect and interact with any running Windows app using UI Automation (UIA). " + "Works with WPF, WinForms, Win32, Electron, and WinUI 3 apps.") { @@ -49,5 +50,6 @@ public UiCommand( Subcommands.Add(waitForCommand); Subcommands.Add(listWindowsCommand); Subcommands.Add(getFocusedCommand); + Subcommands.Add(auditCommand); } } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/CliSchema.cs b/src/winapp-CLI/WinApp.Cli/Helpers/CliSchema.cs index 7f83ff0a..b77ae4a2 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/CliSchema.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/CliSchema.cs @@ -22,6 +22,7 @@ namespace WinApp.Cli.Helpers; [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IfExists))] [JsonSerializable(typeof(ManifestTemplates))] +[JsonSerializable(typeof(float))] [JsonSourceGenerationOptions( WriteIndented = true, NewLine = "\n", diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/ContrastAnalyzer.cs b/src/winapp-CLI/WinApp.Cli/Helpers/ContrastAnalyzer.cs new file mode 100644 index 00000000..33b2b53f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/ContrastAnalyzer.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers; + +/// +/// Computes WCAG 2.x contrast ratios from captured pixels. Pure functions only — no UIA or +/// capture dependencies — so the logic is unit-testable with synthetic pixel buffers. +/// +internal static class ContrastAnalyzer +{ + /// + /// A rectangle in buffer (pixel) coordinates. + /// + internal readonly record struct PixelRect(int X, int Y, int Width, int Height); + + /// Pixels with an alpha below this are treated as transparent and ignored. + private const byte AlphaOpaqueThreshold = 250; + + /// + /// A rect is "not measured" (returns null) unless at least this fraction of its pixels are + /// opaque. Guards transparent/layered regions that would otherwise be scored as raw (often + /// black) RGB. + /// + private const double MinOpaqueFraction = 0.40; + + /// Foreground (glyph) cluster must contain at least this many opaque pixels. + private const int MinForegroundPixels = 8; + + /// Foreground (glyph) cluster must be at least this fraction of opaque pixels. + private const double MinForegroundFraction = 0.005; + + /// + /// Below this luminance spread the region is treated as effectively uniform (no measurable + /// text), so no contrast is reported. Prevents solid fills collapsing to a fabricated ~1:1. + /// + private const double UniformLuminanceEpsilon = 1e-4; + + /// + /// Estimate the WCAG contrast ratio between foreground (text) and background luminance + /// within a region of a BGRA (32-bpp, top-down) pixel buffer. + /// + /// Text glyphs are typically a minority of pixels within a text element's bounding box. This + /// separates a background cluster from a foreground cluster (1D Otsu split over opaque-pixel + /// luminance) and reports the ratio between their representative luminances. Alpha is honored: + /// transparent/layered pixels are ignored, and a mostly-transparent rect is "not measured". + /// + /// Returns null when the region cannot be meaningfully measured — degenerate/out-of-buffer, + /// mostly transparent, effectively uniform (no glyphs), or the glyph cluster is too small to + /// trust (sparse-glyph guard). This deliberately avoids fabricating a low ratio for sparse or + /// transparent content. + /// + public static double? ComputeContrastRatio(ReadOnlySpan bgra, int width, int height, PixelRect rect) + { + if (width <= 0 || height <= 0 || rect.Width <= 0 || rect.Height <= 0) + { + return null; + } + if ((long)width * height * 4 > bgra.Length) + { + return null; + } + + // Clamp the region to the buffer bounds. + var x0 = Math.Max(0, rect.X); + var y0 = Math.Max(0, rect.Y); + var x1 = Math.Min(width, rect.X + rect.Width); + var y1 = Math.Min(height, rect.Y + rect.Height); + if (x1 <= x0 || y1 <= y0) + { + return null; + } + + var totalRectPixels = (x1 - x0) * (y1 - y0); + + // Collect luminances of opaque pixels only. Transparent/layered pixels are skipped so a + // transparent overlay is never scored as its raw (often black) RGB. + var luminances = new double[totalRectPixels]; + var opaqueCount = 0; + for (var y = y0; y < y1; y++) + { + var rowOffset = y * width * 4; + for (var x = x0; x < x1; x++) + { + var p = rowOffset + x * 4; + // BGRA order. + var b = bgra[p]; + var g = bgra[p + 1]; + var r = bgra[p + 2]; + var a = bgra[p + 3]; + if (a < AlphaOpaqueThreshold) + { + continue; + } + luminances[opaqueCount++] = RelativeLuminance(r, g, b); + } + } + + // Mostly transparent (or fully) → not measured. + if (opaqueCount == 0 || (double)opaqueCount / totalRectPixels < MinOpaqueFraction) + { + return null; + } + + var opaque = luminances.AsSpan(0, opaqueCount); + opaque.Sort(); + + // Effectively uniform region (a solid fill, not text) → not measured. + if (opaque[opaqueCount - 1] - opaque[0] < UniformLuminanceEpsilon) + { + return null; + } + + // Separate a background cluster from a foreground (glyph) cluster via a 1D Otsu split that + // maximizes between-class variance, then require the minority (glyph) cluster to clear a + // small coverage floor. This stops short text from collapsing to a fabricated ~1:1. + var (lowMean, lowCount, highMean, highCount) = OtsuSplit(opaque); + var minorityCount = Math.Min(lowCount, highCount); + var floor = Math.Max(MinForegroundPixels, (int)Math.Ceiling(opaqueCount * MinForegroundFraction)); + if (minorityCount < floor) + { + return null; + } + + return ContrastRatio(lowMean, highMean); + } + + /// + /// 1D two-class split (Otsu) over a sorted span of luminances. Returns the mean and pixel + /// count of the low and high clusters using the split index that maximizes between-class + /// variance. + /// + private static (double LowMean, int LowCount, double HighMean, int HighCount) OtsuSplit(ReadOnlySpan sorted) + { + var n = sorted.Length; + var total = 0.0; + for (var i = 0; i < n; i++) + { + total += sorted[i]; + } + + var sumLow = 0.0; + var bestBetween = -1.0; + var bestIdx = 0; // low cluster = [0..bestIdx] + for (var i = 0; i < n - 1; i++) + { + sumLow += sorted[i]; + var wLow = i + 1; + var wHigh = n - wLow; + var meanLow = sumLow / wLow; + var meanHigh = (total - sumLow) / wHigh; + var diff = meanLow - meanHigh; + var between = (double)wLow * wHigh * diff * diff; + if (between > bestBetween) + { + bestBetween = between; + bestIdx = i; + } + } + + var lowCount = bestIdx + 1; + var highCount = n - lowCount; + var lowSum = 0.0; + for (var i = 0; i < lowCount; i++) + { + lowSum += sorted[i]; + } + var lowMean = lowSum / lowCount; + var highMean = (total - lowSum) / highCount; + return (lowMean, lowCount, highMean, highCount); + } + + /// + /// WCAG contrast ratio between two relative luminances (order-independent). + /// + public static double ContrastRatio(double l1, double l2) + { + var lighter = Math.Max(l1, l2); + var darker = Math.Min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + } + + /// + /// WCAG relative luminance for an sRGB color (channels 0-255). + /// + public static double RelativeLuminance(byte r, byte g, byte b) + { + var rl = Linearize(r / 255.0); + var gl = Linearize(g / 255.0); + var bl = Linearize(b / 255.0); + return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; + } + + private static double Linearize(double c) + => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index a55b1f2e..b4240498 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -7,6 +7,7 @@ using System.CommandLine.Invocation; using System.Diagnostics.CodeAnalysis; using WinApp.Cli.Commands; +using WinApp.Cli.Helpers.UiAudit; using WinApp.Cli.Services; namespace WinApp.Cli.Helpers; @@ -56,7 +57,15 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + // UI audit area engines + orchestrator (one engine per --area). + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); } public static IServiceCollection ConfigureCommands(this IServiceCollection serviceCollection) @@ -103,6 +112,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .ConfigureCommand(); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AreaEngines.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AreaEngines.cs new file mode 100644 index 00000000..f03cc062 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AreaEngines.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// Base for area engines that are implemented by delegating to the low-level, pure +/// with a profile-dependent subset of rule checks. This keeps all of +/// today's rule logic in one place while presenting it through the modular area/profile surface. +/// +internal abstract class CheckBackedAreaEngine : IUiAuditAreaEngine +{ + public abstract string Area { get; } + + public virtual bool RequiresContrastCapture => false; + + /// + /// Low-level checks this area contributes for the given profile. + /// Return an empty set to make this a reserved (no-op) extension point. + /// + protected abstract IReadOnlyList ResolveChecks(string profile); + + public UiAuditResult Evaluate(UiAuditContext context) + { + var checks = ResolveChecks(context.Profile); + if (checks.Count == 0) + { + return EmptyResult; + } + + var options = new UiAuditEngine.Options + { + Checks = new HashSet(checks, StringComparer.OrdinalIgnoreCase), + Profile = context.Profile, + NormalContrast = context.NormalContrast, + LargeContrast = context.LargeContrast, + WcagLevel = context.WcagLevel, + }; + + return UiAuditEngine.Run(context.Elements, options, context.ContrastProvider); + } + + protected static UiAuditResult EmptyResult => new() + { + Summary = new UiAuditSummary(), + Issues = [], + }; +} + +/// Accessible-name coverage on interactive/focusable elements. +internal sealed class NamesAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.Names; + + protected override IReadOnlyList ResolveChecks(string profile) + => [UiAuditEngine.CheckNames]; +} + +/// +/// Keyboard reachability. checks focusability; the more expensive +/// tab-order coherence heuristic is added at . +/// +internal sealed class KeyboardAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.Keyboard; + + protected override IReadOnlyList ResolveChecks(string profile) + => profile == AuditProfile.Thorough + ? [UiAuditEngine.CheckKeyboard, UiAuditEngine.CheckTabOrder] + : [UiAuditEngine.CheckKeyboard]; +} + +/// Control-type / role clarity on actionable elements. +internal sealed class RolesAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.Roles; + + protected override IReadOnlyList ResolveChecks(string profile) + => [UiAuditEngine.CheckRoles]; +} + +/// WCAG color-contrast of visible text (requires captured pixels). +internal sealed class ContrastAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.Contrast; + + public override bool RequiresContrastCapture => true; + + protected override IReadOnlyList ResolveChecks(string profile) + => [UiAuditEngine.CheckContrast]; +} + +/// +/// Screen-reader affordances. Reserved extension point: a future engine will drive a screen-reader +/// bridge / UIA text patterns to validate announced content. Returns no findings today. +/// +internal sealed class ScreenReaderAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.ScreenReader; + + // Static readiness proxy: checks what a screen reader would be able to perceive from the + // current UIA tree (name, role clarity, focus reachability). Dynamic event/live-region + // validation stays in the reserved `events` area for a later pass. + protected override IReadOnlyList ResolveChecks(string profile) + => [UiAuditEngine.CheckScreenReader]; +} + +/// +/// UIA event / interaction behavior. Reserved extension point: a future engine will subscribe to +/// UIA events (focus, invoke, property-changed) while exercising the UI. Returns no findings today. +/// +internal sealed class EventsAreaEngine : CheckBackedAreaEngine +{ + public override string Area => AuditArea.Events; + + // TODO(events): Subscribe to UIA automation events and assert that interactions raise the + // expected notifications; needs a time budget (see UiAuditContext.TimeBudget) to bound waits. + protected override IReadOnlyList ResolveChecks(string profile) => []; +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditArea.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditArea.cs new file mode 100644 index 00000000..b37bdf5d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditArea.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// Well-known audit areas — the high-level, user-facing grouping of accessibility +/// concerns surfaced by ui audit via --area. Each area is implemented independently +/// by an so new areas can be added without touching existing ones. +/// +/// This is the primary extension point for scaling the audit: to add coverage for a new concern, +/// add a constant here, add it to , and register a matching engine in DI. +/// +/// +internal static class AuditArea +{ + /// Accessible-name coverage on interactive/focusable elements. + public const string Names = "names"; + + /// Keyboard reachability (and, in , tab-order coherence). + public const string Keyboard = "keyboard"; + + /// Screen-reader affordances (static readiness proxy over the UIA tree). + public const string ScreenReader = "screen-reader"; + + /// WCAG color-contrast of visible text. Requires a captured-pixel provider. + public const string Contrast = "contrast"; + + /// Control-type / role clarity on actionable elements. + public const string Roles = "roles"; + + /// + /// UIA event/interaction behavior. Reserved extension point (see + /// TODO). Not yet user-selectable — its engine is a registered no-op until dynamic event + /// validation lands, so it is intentionally excluded from and + /// and --area events is rejected as invalid for now. + /// + public const string Events = "events"; + + /// Meta-selector expanding to every area. + public const string All = "all"; + + /// + /// User-facing areas with a registered, active engine, in stable display order. Extend this + /// (and DI) when adding a new area engine. Reserved no-op areas (e.g. ) are + /// intentionally omitted until they produce findings. + /// + public static readonly IReadOnlyList Implemented = + [Names, Keyboard, ScreenReader, Contrast, Roles]; + + /// Values a user may pass to --area (implemented areas plus all). + public static readonly IReadOnlyList Selectable = [.. Implemented, All]; + + /// + /// Normalize and expand a raw --area selection into a de-duplicated, ordered set of + /// concrete area names. An empty selection defaults to . all anywhere + /// expands to every area. + /// + /// User-supplied area tokens (may be empty). + /// First unrecognized token, if any. + /// The resolved ordered area set, or null when a token was invalid. + public static IReadOnlyList? Resolve(IReadOnlyList rawAreas, out string? invalid) + { + invalid = null; + if (rawAreas.Count == 0) + { + return Implemented; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var raw in rawAreas) + { + var normalized = raw.Trim().ToLowerInvariant(); + if (normalized == All) + { + return Implemented; + } + if (!Selectable.Contains(normalized)) + { + invalid = raw; + return null; + } + seen.Add(normalized); + } + + // Preserve the canonical Implemented ordering for deterministic output. + return Implemented.Where(seen.Contains).ToArray(); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditProfile.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditProfile.cs new file mode 100644 index 00000000..565efed3 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/AuditProfile.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// Audit levels select how deeply each area probes and which WCAG contrast thresholds +/// apply: runs the fast, essential rules with WCAG AA contrast; +/// adds heuristic / deeper rules and applies WCAG AAA contrast. Exposed via --level, this lets a +/// single --area selection scale from a quick CI gate to a comprehensive sweep without new commands. +/// +internal static class AuditProfile +{ + /// Fast, high-signal essential rules (WCAG AA contrast). The default level. + public const string Basic = "basic"; + + /// Superset of adding heuristic / deeper rules and WCAG AAA contrast. + public const string Thorough = "thorough"; + + /// Accepted --level values. + public static readonly IReadOnlyList All = [Basic, Thorough]; + + /// Normalize a raw level token; returns null when unrecognized. + public static string? Normalize(string? raw) + { + var normalized = (raw ?? Basic).Trim().ToLowerInvariant(); + return All.Contains(normalized) ? normalized : null; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/IUiAuditAreaEngine.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/IUiAuditAreaEngine.cs new file mode 100644 index 00000000..0653ae8e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/IUiAuditAreaEngine.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// One independent audit area. Implementations evaluate a single over the shared +/// and return only their own findings; the +/// merges results across areas. This is the core extension point: +/// adding a new engine (and registering it in DI) adds a new --area with no changes to the +/// orchestrator or command. +/// +internal interface IUiAuditAreaEngine +{ + /// The name this engine implements. + string Area { get; } + + /// + /// true when this area needs measured contrast ratios, so the orchestrator can lazily + /// perform the (relatively expensive) window pixel capture only when required. + /// + bool RequiresContrastCapture { get; } + + /// Evaluate this area and return its findings (never null). + UiAuditResult Evaluate(UiAuditContext context); +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditContext.cs new file mode 100644 index 00000000..cadbccaf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// Immutable inputs shared by every for one audit run. Keeping +/// this as a single context object lets area engines stay independent and lets the orchestrator +/// add new inputs (e.g. a time budget) without changing engine signatures. +/// +internal sealed class UiAuditContext +{ + /// Flat, walk-ordered element list produced by the inspect walk. + public required IReadOnlyList Elements { get; init; } + + /// Selected profile (see ). Controls per-area rule depth. + public required string Profile { get; init; } + + /// WCAG contrast threshold for normal-size text. + public double NormalContrast { get; init; } = 4.5; + + /// WCAG contrast threshold for large text. + public double LargeContrast { get; init; } = 3.0; + + /// Informational WCAG level ("AA"/"AAA") echoed into messages. + public string WcagLevel { get; init; } = "AA"; + + /// + /// Measured contrast ratio provider (from captured pixels), or null when capture was + /// unavailable. Only areas that set + /// consult it. + /// + public Func? ContrastProvider { get; init; } + + // TODO(time-budget): Carry an optional per-run/per-area time budget here so dynamic areas + // (screen-reader, events) can bound how long they interact with the live UI. Plumbed as a + // nullable so today's synchronous engines can ignore it. + public TimeSpan? TimeBudget { get; init; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditOrchestrator.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditOrchestrator.cs new file mode 100644 index 00000000..38eefd97 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAudit/UiAuditOrchestrator.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; + +namespace WinApp.Cli.Helpers.UiAudit; + +/// +/// Coordinates the per-area s: runs each selected area independently +/// and merges their findings into a single (summing summaries, preserving +/// area order). Engines are supplied via DI, so registering a new engine automatically extends the +/// set of runnable areas without changing this class. +/// +internal sealed class UiAuditOrchestrator +{ + private readonly Dictionary _engines; + + public UiAuditOrchestrator(IEnumerable engines) + { + _engines = engines.ToDictionary(e => e.Area, StringComparer.OrdinalIgnoreCase); + } + + /// Areas that have a registered engine. + public IReadOnlyCollection AvailableAreas => _engines.Keys; + + /// Whether any of needs a contrast pixel capture. + public bool AnyRequiresContrastCapture(IEnumerable areas) + => areas.Any(a => _engines.TryGetValue(a, out var e) && e.RequiresContrastCapture); + + /// + /// Run the given in order and merge their findings. Unknown area + /// names are skipped (the command validates selections up front). + /// + public UiAuditResult Run(IReadOnlyList areas, UiAuditContext context) + { + var issues = new List(); + var pass = 0; + + foreach (var area in areas) + { + if (!_engines.TryGetValue(area, out var engine)) + { + continue; + } + + var result = engine.Evaluate(context); + issues.AddRange(result.Issues); + pass += result.Summary.Pass; + } + + // Cross-area de-duplication: when several areas surface the SAME underlying defect for the + // same element (e.g. a missing accessible name reported by names + keyboard + screen-reader), + // keep only the first — preserving area order so the most canonical finding wins — instead of + // triple-counting it and inflating the fail total / CI exit semantics. Findings without a + // shared root cause (or without a selector to correlate on) are always preserved, so + // single-area runs stay fully meaningful. + var deduped = Deduplicate(issues); + + var warn = deduped.Count(i => i.Severity == UiAuditEngine.SeverityWarn); + var fail = deduped.Count(i => i.Severity == UiAuditEngine.SeverityFail); + + return new UiAuditResult + { + Summary = new UiAuditSummary { Pass = pass, Warn = warn, Fail = fail }, + Issues = deduped.ToArray(), + }; + } + + /// + /// Collapse findings that share the same (Selector, RootCause) — the same defect reported by + /// more than one area — keeping the first occurrence. Findings with no RootCause or no Selector + /// are always kept. + /// + private static List Deduplicate(List issues) + { + var seen = new HashSet<(string Selector, string RootCause)>(); + var deduped = new List(issues.Count); + foreach (var issue in issues) + { + if (!string.IsNullOrEmpty(issue.RootCause) && !string.IsNullOrEmpty(issue.Selector)) + { + if (!seen.Add((issue.Selector!, issue.RootCause!))) + { + continue; + } + } + deduped.Add(issue); + } + return deduped; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiAuditEngine.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiAuditEngine.cs new file mode 100644 index 00000000..de44a4d2 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiAuditEngine.cs @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Helpers.UiAudit; + +namespace WinApp.Cli.Helpers; + +/// +/// Pure accessibility + contrast rule engine for ui audit. Operates on a flat list of +/// inspected s and emits findings. No UIA/capture dependencies, so it is +/// fully unit-testable with fabricated elements. Contrast ratios are supplied by the caller via a +/// provider delegate (computed from captured pixels) to keep pixel sampling out of the engine. +/// +internal static class UiAuditEngine +{ + public const string CheckNames = "names"; + public const string CheckKeyboard = "keyboard"; + public const string CheckRoles = "roles"; + public const string CheckScreenReader = "screen-reader"; + public const string CheckTabOrder = "tab-order"; + public const string CheckContrast = "contrast"; + + public const string SeverityFail = "fail"; + public const string SeverityWarn = "warn"; + + // Root-cause tags used to de-duplicate the SAME underlying defect when it is surfaced by more + // than one area (e.g. a missing accessible name is reported by names, keyboard, and + // screen-reader). The orchestrator collapses cross-area findings that share (Selector, RootCause). + public const string RootCauseMissingName = "missing-name"; + public const string RootCauseNotFocusable = "not-focusable"; + public const string RootCauseUnclearRole = "unclear-role"; + + public static readonly IReadOnlyList AllChecks = + [CheckNames, CheckKeyboard, CheckRoles, CheckScreenReader, CheckTabOrder, CheckContrast]; + + /// Options controlling which rules run and their thresholds. + internal sealed class Options + { + public required HashSet Checks { get; init; } + public string Profile { get; init; } = AuditProfile.Basic; + + /// WCAG contrast threshold for normal-size text. + public double NormalContrast { get; init; } = 4.5; + + /// WCAG contrast threshold for large text (>= ~24px, or ~18.66px bold). + public double LargeContrast { get; init; } = 3.0; + + /// Informational: "AA" or "AAA". + public string WcagLevel { get; init; } = "AA"; + } + + // ControlTypes conventionally considered interactive (mirrors UiInspectCommand's allowlist). + private static readonly HashSet InteractiveTypes = new(StringComparer.OrdinalIgnoreCase) + { + "Button", "CheckBox", "ComboBox", "Edit", "TextBox", "Hyperlink", + "ListItem", "MenuItem", "RadioButton", "Tab", "TabItem", "SplitButton", + "TreeItem", "DataItem", "Slider" + }; + + // ControlTypes that carry visible text worth contrast-checking. + private static readonly HashSet TextTypes = new(StringComparer.OrdinalIgnoreCase) + { + "Text", "Document", "Hyperlink" + }; + + // Non-client / chrome ControlTypes that are structurally part of the window frame or scroll + // machinery rather than app content. Suppressed from name/keyboard/screen-reader rules. + private static readonly HashSet ChromeTypes = new(StringComparer.OrdinalIgnoreCase) + { + "ScrollBar", "Thumb", "TitleBar" + }; + + // System-provided accessible names for scrollbar increment/decrement parts. These are + // framework-generated, unambiguous, and effectively never real user-facing control names, so + // flagging them is noise. Only unambiguous part names are listed — caption buttons and the + // ambiguous bare part names (e.g. "Close", "Page Down") are intentionally left out and covered + // structurally by ChromeTypes (ScrollBar/Thumb) + TitleBar ancestry instead. This is only a + // fallback for parts exposed as generic Buttons. + private static readonly HashSet ChromePartNames = new(StringComparer.OrdinalIgnoreCase) + { + "Vertical Small Increase", "Vertical Small Decrease", + "Vertical Large Increase", "Vertical Large Decrease", + "Horizontal Small Increase", "Horizontal Small Decrease", + "Horizontal Large Increase", "Horizontal Large Decrease", + }; + + private const string TitleBarType = "TitleBar"; + + /// Large-text px threshold (WCAG large text ≈ 18pt ≈ 24px). + private const double LargeTextHeightPx = 24.0; + + public static bool IsInteractive(UiElement el) + => el.IsInvokable || InteractiveTypes.Contains(el.Type); + + /// + /// True when the element is non-client window chrome (title-bar / caption buttons) or scroll-bar + /// part machinery, which the accessibility rules should not flag: these are framework-owned + /// affordances, keyboard-reachable via the scrollbar/window as a whole, not app content. Detected + /// primarily by ControlType (ScrollBar/Thumb/TitleBar) and TitleBar ancestry, with a fallback on + /// system-generated part names. + /// + public static bool IsNonClientChrome(UiElement el) + { + if (ChromeTypes.Contains(el.Type)) + { + return true; + } + + if (el.AncestorPath is { } path) + { + foreach (var ancestor in path) + { + if (string.Equals(ancestor, TitleBarType, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + if (!string.IsNullOrWhiteSpace(el.Name) && ChromePartNames.Contains(el.Name.Trim())) + { + return true; + } + + return false; + } + + /// + /// Run the enabled rules over . + /// returns the measured contrast ratio for a text element, or null when it could not be + /// measured (in which case the contrast rule is skipped for that element). + /// + public static UiAuditResult Run( + IReadOnlyList elements, + Options options, + Func? contrastProvider = null) + { + var issues = new List(); + var pass = 0; + + foreach (var el in elements) + { + // Skip window separator sentinels emitted by the inspect walk. + if (el.Type == "---") + { + continue; + } + + var interactive = IsInteractive(el); + var visible = !el.IsOffscreen; + + // Non-client window chrome (title bar / caption buttons) and scrollbar part machinery + // are framework-owned affordances, not app content. Suppress them from the + // name/keyboard/screen-reader rules to avoid false positives. + var chrome = IsNonClientChrome(el); + + // names: interactive/focusable elements must have a non-empty accessible name. + if (options.Checks.Contains(CheckNames) && visible && !chrome && (interactive || el.IsKeyboardFocusable)) + { + if (string.IsNullOrWhiteSpace(el.Name)) + { + issues.Add(Issue(CheckNames, SeverityFail, el, + $"{Describe(el)} is interactive but has no accessible name (Name is empty). Set an accessible label (e.g. AutomationProperties.Name / aria-label).", + RootCauseMissingName)); + } + else + { + pass++; + } + + if (!string.IsNullOrWhiteSpace(el.Name)) + { + if (!string.IsNullOrWhiteSpace(el.AutomationId) + && string.Equals(el.Name, el.AutomationId, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue(CheckNames, SeverityWarn, el, + $"{Describe(el)} uses its AutomationId as its accessible name. Provide a user-facing label instead of a control identifier.")); + } + else if (!string.IsNullOrWhiteSpace(el.ClassName) + && string.Equals(el.Name, el.ClassName, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue(CheckNames, SeverityWarn, el, + $"{Describe(el)} uses its class name as its accessible name. Provide a user-facing label instead of an implementation detail.")); + } + else if (options.Profile == AuditProfile.Thorough + && string.Equals(el.Name, el.Type, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue(CheckNames, SeverityWarn, el, + $"{Describe(el)} uses its control type as its accessible name. Users need a meaningful name, not just the role.")); + } + } + } + + // keyboard: interactive, enabled, visible elements should be keyboard-focusable. + if (options.Checks.Contains(CheckKeyboard) && !chrome && interactive && el.IsEnabled && visible) + { + if (!el.IsKeyboardFocusable) + { + issues.Add(Issue(CheckKeyboard, SeverityWarn, el, + $"{Describe(el)} is interactive but not keyboard-focusable. Keyboard-only users cannot reach it.", + RootCauseNotFocusable)); + } + else + { + pass++; + } + } + + if (options.Checks.Contains(CheckKeyboard) && !chrome) + { + if (el.IsKeyboardFocusable && visible && string.IsNullOrWhiteSpace(el.Name)) + { + issues.Add(Issue(CheckKeyboard, SeverityFail, el, + $"{Describe(el)} is keyboard-focusable but has no accessible name. Keyboard and assistive-tech users will land on an unnamed stop.", + RootCauseMissingName)); + } + + if (el.IsKeyboardFocusable && !el.IsEnabled && visible) + { + issues.Add(Issue(CheckKeyboard, SeverityWarn, el, + $"{Describe(el)} is disabled but still keyboard-focusable. Disabled controls should usually be skipped by tab navigation.")); + } + + if (el.IsKeyboardFocusable && el.IsOffscreen) + { + issues.Add(Issue(CheckKeyboard, SeverityWarn, el, + $"{Describe(el)} can receive keyboard focus while offscreen. Users may lose track of focus location.")); + } + } + + // roles: actionable elements should expose a sensible ControlType (not Custom/Unknown). + if (options.Checks.Contains(CheckRoles) && el.IsInvokable && visible) + { + if (HasUnknownRole(el.Type)) + { + issues.Add(Issue(CheckRoles, SeverityWarn, el, + $"{Describe(el)} is actionable but exposes an unclear ControlType ('{el.Type}'). Assistive tech may misannounce it — assign a proper control type/role.", + RootCauseUnclearRole)); + } + else + { + pass++; + } + + if (el.Type.Equals("Pane", StringComparison.OrdinalIgnoreCase) + || el.Type.Equals("Group", StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue(CheckRoles, SeverityWarn, el, + $"{Describe(el)} is actionable but exposes a container role ('{el.Type}'). Screen readers may not announce it as an actionable control.")); + } + else if (options.Profile == AuditProfile.Thorough + && (el.Type.Equals("Text", StringComparison.OrdinalIgnoreCase) + || el.Type.Equals("Image", StringComparison.OrdinalIgnoreCase))) + { + issues.Add(Issue(CheckRoles, SeverityWarn, el, + $"{Describe(el)} is actionable but exposes a presentational role ('{el.Type}'). Consider a clearer actionable control type.")); + } + } + + if (options.Checks.Contains(CheckScreenReader) && !chrome) + { + if ((interactive || el.IsKeyboardFocusable) && visible && string.IsNullOrWhiteSpace(el.Name)) + { + issues.Add(Issue(CheckScreenReader, SeverityFail, el, + $"{Describe(el)} is reachable by assistive technology but has no accessible name. A screen reader would announce little or nothing useful.", + RootCauseMissingName)); + } + + if (interactive && el.IsEnabled && visible && !el.IsKeyboardFocusable) + { + issues.Add(Issue(CheckScreenReader, SeverityWarn, el, + $"{Describe(el)} is actionable but not keyboard-focusable. Screen-reader users navigating by focus may not be able to reach it.", + RootCauseNotFocusable)); + } + + if (interactive && visible && HasUnknownRole(el.Type)) + { + issues.Add(Issue(CheckScreenReader, SeverityWarn, el, + $"{Describe(el)} exposes an unclear role ('{el.Type}'). Screen readers may announce it ambiguously.", + RootCauseUnclearRole)); + } + + if (options.Profile == AuditProfile.Thorough + && el.IsKeyboardFocusable + && !string.IsNullOrWhiteSpace(el.Name) + && string.Equals(el.Name, el.Type, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(Issue(CheckScreenReader, SeverityWarn, el, + $"{Describe(el)} has a low-value accessible name that duplicates its role. Screen-reader users need a descriptive label.")); + } + } + + // contrast: text elements must meet the WCAG ratio for their size. + if (options.Checks.Contains(CheckContrast) && visible && contrastProvider is not null && IsTextElement(el)) + { + var ratio = contrastProvider(el); + if (ratio is { } r) + { + var isLarge = el.Height >= LargeTextHeightPx; + var threshold = isLarge ? options.LargeContrast : options.NormalContrast; + if (r + 0.05 < threshold) // small epsilon so exact-threshold passes + { + issues.Add(Issue(CheckContrast, SeverityFail, el, + $"{Describe(el)} contrast ratio {r:0.00}:1 is below the WCAG {options.WcagLevel} threshold {threshold:0.0}:1 for {(isLarge ? "large" : "normal")} text.")); + } + else + { + pass++; + } + } + } + } + + // tab-order: coherence heuristic over the focusable elements in walk order. + if (options.Checks.Contains(CheckTabOrder)) + { + EvaluateTabOrder(elements, issues, ref pass); + } + + var warn = issues.Count(i => i.Severity == SeverityWarn); + var fail = issues.Count(i => i.Severity == SeverityFail); + + return new UiAuditResult + { + Summary = new UiAuditSummary { Pass = pass, Warn = warn, Fail = fail }, + Issues = issues.ToArray(), + }; + } + + /// + /// Simple tab-order coherence heuristic: focusable, visible elements are expected to progress + /// roughly top-to-bottom, left-to-right in walk order. Report large backward jumps (an element + /// whose top is well above the previous focusable element's top) as anomalies. + /// + private static void EvaluateTabOrder(IReadOnlyList elements, List issues, ref int pass) + { + const double backwardTolerancePx = 24.0; + + UiElement? prev = null; + foreach (var el in elements) + { + if (el.Type == "---" || el.IsOffscreen || !el.IsKeyboardFocusable) + { + continue; + } + + if (prev is not null) + { + var movedToNewRow = el.Y > prev.Y + backwardTolerancePx; + var sameRow = Math.Abs(el.Y - prev.Y) <= backwardTolerancePx; + var backwardOnRow = sameRow && el.X + backwardTolerancePx < prev.X; + var jumpedUp = el.Y + backwardTolerancePx < prev.Y; + + if (!movedToNewRow && (backwardOnRow || jumpedUp)) + { + issues.Add(Issue(CheckTabOrder, SeverityWarn, el, + $"{Describe(el)} appears out of tab order — it sits above/left of the previous focusable element, so keyboard focus may jump unexpectedly.")); + } + else + { + pass++; + } + } + + prev = el; + } + } + + private static bool IsTextElement(UiElement el) + => TextTypes.Contains(el.Type) && !string.IsNullOrWhiteSpace(el.Name) && el.Width > 0 && el.Height > 0; + + private static bool HasUnknownRole(string type) + => string.IsNullOrWhiteSpace(type) + || type.StartsWith("Unknown", StringComparison.OrdinalIgnoreCase) + || type.Equals("Custom", StringComparison.OrdinalIgnoreCase); + + private static UiAuditIssue Issue(string ruleId, string severity, UiElement el, string message, string? rootCause = null) + => new() + { + RuleId = ruleId, + Severity = severity, + Selector = el.Selector ?? el.Id, + Name = el.Name, + Message = message, + RootCause = rootCause, + }; + + private static string Describe(UiElement el) + { + var label = !string.IsNullOrWhiteSpace(el.Name) ? $" \"{el.Name}\"" + : !string.IsNullOrWhiteSpace(el.AutomationId) ? $" #{el.AutomationId}" + : ""; + return $"{el.Type}{label}"; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs index 9abee25c..ddfb0469 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs @@ -39,6 +39,10 @@ namespace WinApp.Cli.Helpers; [JsonSerializable(typeof(UiScreenshotWindowInfo))] [JsonSerializable(typeof(WindowInfo))] [JsonSerializable(typeof(WindowInfo[]))] +[JsonSerializable(typeof(UiAuditResult))] +[JsonSerializable(typeof(UiAuditSummary))] +[JsonSerializable(typeof(UiAuditIssue))] +[JsonSerializable(typeof(UiAuditIssue[]))] [JsonSourceGenerationOptions( WriteIndented = true, NewLine = "\n", @@ -189,9 +193,7 @@ internal sealed class UiGetValueResult { public string ElementId { get; set; } = ""; public string? Text { get; set; } -} - -internal sealed class UiScrollResult +}internal sealed class UiScrollResult { public string ElementId { get; set; } = ""; public string? Direction { get; set; } @@ -249,3 +251,44 @@ internal sealed class UiDragResult public int DwellMs { get; set; } public long Hwnd { get; set; } } + +/// Top-level result for ui audit. Shape: { summary, issues }. +internal sealed class UiAuditResult +{ + public UiAuditSummary Summary { get; set; } = new(); + public UiAuditIssue[] Issues { get; set; } = []; +} + +/// Roll-up counts across all evaluated rule checks. +internal sealed class UiAuditSummary +{ + /// Number of individual rule checks that passed. + public int Pass { get; set; } + /// Number of warn-severity issues. + public int Warn { get; set; } + /// Number of fail-severity issues (drives the non-zero CI exit code). + public int Fail { get; set; } +} + +/// A single accessibility/contrast finding. +internal sealed class UiAuditIssue +{ + /// Rule identifier: names, keyboard, roles, tab-order, contrast. + public string RuleId { get; set; } = ""; + /// "fail" or "warn". + public string Severity { get; set; } = ""; + /// Stable selector slug of the offending element, when available. + public string? Selector { get; set; } + /// Accessible name of the element, when available. + public string? Name { get; set; } + /// Human-readable explanation of the finding. + public string Message { get; set; } = ""; + + /// + /// Internal correlation tag for the underlying defect (e.g. "missing-name"). Used by the + /// orchestrator to collapse the SAME root cause when several areas surface it for one element. + /// Not serialized. + /// + [System.Text.Json.Serialization.JsonIgnore] + public string? RootCause { get; set; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Models/UiElement.cs b/src/winapp-CLI/WinApp.Cli/Models/UiElement.cs index 3a7a4956..8380e699 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/UiElement.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/UiElement.cs @@ -54,6 +54,12 @@ internal sealed class UiElement /// True when this element supports a directly invokable UIA pattern (Invoke/Toggle/SelectionItem/ExpandCollapse). Used by --interactive filtering. public bool IsInvokable { get; set; } + /// True when the element can receive keyboard focus (UIA IsKeyboardFocusable). + /// Populated during the inspect tree walk; used by the accessibility audit's keyboard rule. + /// Not part of the serialized inspect output. + [System.Text.Json.Serialization.JsonIgnore] + public bool IsKeyboardFocusable { get; set; } + /// True when the element has additional descendants that were not included /// because the inspect depth limit was reached. Hint to re-run with a deeper --depth. public bool? HasMoreChildren { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs index ed2effe9..b671f557 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs @@ -28,6 +28,13 @@ internal interface IUiAutomationService Task FindSingleElementAsync(UiSessionInfo session, SelectorExpression selector, CancellationToken ct); Task> GetPropertiesAsync(UiSessionInfo session, UiElement element, string? propertyName, CancellationToken ct); Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct); + + /// + /// Capture the full target window's pixels (BGRA, top-down) along with the screen-space + /// origin of the buffer's top-left pixel. Used by the accessibility audit to sample + /// text/background luminance within element bounding rectangles for contrast analysis. + /// + Task<(byte[] Pixels, int Width, int Height, int OriginX, int OriginY)> CaptureWindowAsync(UiSessionInfo session, CancellationToken ct); Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct); Task SetValueAsync(UiSessionInfo session, UiElement element, string text, CancellationToken ct); Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct); diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs index 5e2d1577..2e3e13f1 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs @@ -16,6 +16,33 @@ internal sealed partial class UiAutomationService { _logger.LogDebug("Taking screenshot of process {Pid} (captureScreen={CaptureScreen}, focus={Focus})", session.ProcessId, captureScreen, focus); + var capture = await CaptureWindowRawAsync(session, captureScreen, focus, ct).ConfigureAwait(false); + + // If a selector was provided, crop to the element's bounding rectangle + if (!string.IsNullOrEmpty(elementId)) + { + var cropped = CropToElement(capture.Pixels, capture.Width, capture.Height, elementId, session, capture.Root, capture.OriginLeft, capture.OriginTop); + if (cropped is not null) + { + return cropped.Value; + } + } + + return (capture.Pixels, capture.Width, capture.Height); + } + + public async Task<(byte[] Pixels, int Width, int Height, int OriginX, int OriginY)> CaptureWindowAsync(UiSessionInfo session, CancellationToken ct) + { + _logger.LogDebug("Capturing full window pixels of process {Pid} for contrast analysis", session.ProcessId); + var capture = await CaptureWindowRawAsync(session, captureScreen: false, focus: false, ct).ConfigureAwait(false); + return (capture.Pixels, capture.Width, capture.Height, capture.OriginLeft, capture.OriginTop); + } + + private readonly record struct WindowCaptureResult( + byte[] Pixels, int Width, int Height, int OriginLeft, int OriginTop, IUIAutomationElement Root); + + private async Task CaptureWindowRawAsync(UiSessionInfo session, bool captureScreen, bool focus, CancellationToken ct) + { var root = GetRootElement(session); if (root is null) { @@ -103,17 +130,7 @@ internal sealed partial class UiAutomationService pixelData = CaptureFromWindowWithBlankRetry(hwnd, width, height); } - // If a selector was provided, crop to the element's bounding rectangle - if (!string.IsNullOrEmpty(elementId)) - { - var cropped = CropToElement(pixelData, width, height, elementId, session, root, cropOriginLeft, cropOriginTop); - if (cropped is not null) - { - return cropped.Value; - } - } - - return (pixelData, width, height); + return new WindowCaptureResult(pixelData, width, height, cropOriginLeft, cropOriginTop, root); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs index 25149865..9c8b98a3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs @@ -1905,6 +1905,7 @@ private static UiElement ToUiElement(IUIAutomationElement element, string path, ClassName = SafeGetBstr(() => element.get_CurrentClassName()), IsEnabled = element.get_CurrentIsEnabled(), IsOffscreen = element.get_CurrentIsOffscreen(), + IsKeyboardFocusable = SafeGetBool(() => element.get_CurrentIsKeyboardFocusable()), X = rect.left, Y = rect.top, Width = rect.right - rect.left, @@ -2011,6 +2012,18 @@ private void PromoteUniqueAutomationIds(IUIAutomationElement root, IList getter) + { + try + { + return getter(); + } + catch + { + return false; + } + } + private static string GetControlTypeName(UIA_CONTROLTYPE_ID controlType) => controlType switch { UIA_CONTROLTYPE_ID.UIA_ButtonControlTypeId => "Button",