Skip to content

Commit 3e2389c

Browse files
committed
Add accessibility audit engine and CDP AX support
Introduce an accessibility audit subsystem: new abstractions (IAccessibilityRule, AccessibilityNode, AccessibilityViolation, AccessibilityAuditContext/Result, AccessibilityViolationSeverity), a rule engine, and a CDP-based tree query + selector helper. Add a built-in alt-text rule and a rule collection with plugin registration plumbing (PluginContext/BrowserContext integration). Expose transport capabilities on sessions and extend MotusCapabilities (AccessibilityTree, SecurityOverrides) with capability guards where appropriate; non-CDP transports now return a diagnostic message instead of failing. Include unit tests covering the engine, tree query, alt-text rule, and capability guard behavior.
1 parent 3ba7acd commit 3e2389c

31 files changed

Lines changed: 978 additions & 4 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Classifies the severity of an accessibility rule violation.
5+
/// </summary>
6+
public enum AccessibilityViolationSeverity
7+
{
8+
/// <summary>A definite accessibility failure that prevents use.</summary>
9+
Error,
10+
11+
/// <summary>A likely issue that may impair some users.</summary>
12+
Warning,
13+
14+
/// <summary>An informational finding; not necessarily a defect.</summary>
15+
Info
16+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Evaluates a single accessibility rule against one node in the accessibility tree.
5+
/// </summary>
6+
public interface IAccessibilityRule
7+
{
8+
/// <summary>
9+
/// Gets the unique identifier for this rule (e.g. "a11y-alt-text").
10+
/// </summary>
11+
string RuleId { get; }
12+
13+
/// <summary>
14+
/// Gets a human-readable description of what this rule checks.
15+
/// </summary>
16+
string Description { get; }
17+
18+
/// <summary>
19+
/// Evaluates the rule against the given node.
20+
/// </summary>
21+
/// <param name="node">The node to evaluate.</param>
22+
/// <param name="context">The audit context providing tree-wide data and page access.</param>
23+
/// <returns>
24+
/// A <see cref="AccessibilityViolation"/> if this node violates the rule;
25+
/// <c>null</c> if the node passes or this rule does not apply to the node.
26+
/// </returns>
27+
AccessibilityViolation? Evaluate(AccessibilityNode node, AccessibilityAuditContext context);
28+
}

src/Motus.Abstractions/Plugins/IPluginContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ public interface IPluginContext
2929
/// <param name="reporter">The reporter to register.</param>
3030
void RegisterReporter(IReporter reporter);
3131

32+
/// <summary>
33+
/// Registers a custom accessibility rule that will be invoked during accessibility audits.
34+
/// </summary>
35+
/// <param name="rule">The accessibility rule to register.</param>
36+
void RegisterAccessibilityRule(IAccessibilityRule rule);
37+
3238
/// <summary>
3339
/// Creates a logger scoped to the calling plugin.
3440
/// </summary>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Provides rules with access to the full tree and the page during an audit.
5+
/// </summary>
6+
/// <param name="AllNodes">All walkable nodes in the accessibility tree, in depth-first order.</param>
7+
/// <param name="Page">The page being audited, for computed-style queries.</param>
8+
public sealed record AccessibilityAuditContext(
9+
IReadOnlyList<AccessibilityNode> AllNodes,
10+
IPage Page);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// The outcome of an accessibility audit over a page's accessibility tree.
5+
/// </summary>
6+
/// <param name="Violations">All unique violations found during the audit.</param>
7+
/// <param name="PassCount">Number of (node, rule) pairs that passed.</param>
8+
/// <param name="ViolationCount">Number of unique violations found.</param>
9+
/// <param name="Duration">Wall-clock time taken to perform the audit.</param>
10+
/// <param name="DiagnosticMessage">
11+
/// Optional message when the audit could not run fully
12+
/// (e.g. transport does not support accessibility queries).
13+
/// </param>
14+
public sealed record AccessibilityAuditResult(
15+
IReadOnlyList<AccessibilityViolation> Violations,
16+
int PassCount,
17+
int ViolationCount,
18+
TimeSpan Duration,
19+
string? DiagnosticMessage = null);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Represents a single node in the browser's accessibility tree.
5+
/// </summary>
6+
/// <param name="NodeId">The protocol-assigned node identifier.</param>
7+
/// <param name="Role">The ARIA or implicit role (e.g. "button", "img").</param>
8+
/// <param name="Name">The accessible name.</param>
9+
/// <param name="Value">The current value, for form controls.</param>
10+
/// <param name="Description">The accessible description.</param>
11+
/// <param name="Properties">Additional AX properties keyed by property name.</param>
12+
/// <param name="Children">Child nodes in tree order.</param>
13+
/// <param name="BackendDOMNodeId">The backend DOM node ID for element resolution.</param>
14+
/// <param name="Ignored">True if the node was ignored by the browser and excluded from the walkable tree.</param>
15+
public sealed record AccessibilityNode(
16+
string NodeId,
17+
string? Role,
18+
string? Name,
19+
string? Value,
20+
string? Description,
21+
IReadOnlyDictionary<string, string?> Properties,
22+
IReadOnlyList<AccessibilityNode> Children,
23+
long? BackendDOMNodeId,
24+
bool Ignored = false);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace Motus.Abstractions;
2+
3+
/// <summary>
4+
/// Describes a single accessibility rule violation found during an audit.
5+
/// </summary>
6+
/// <param name="RuleId">The identifier of the rule that produced this violation (e.g. "a11y-alt-text").</param>
7+
/// <param name="Severity">The severity of the violation.</param>
8+
/// <param name="Message">A human-readable description of the violation.</param>
9+
/// <param name="NodeRole">The ARIA role of the violating node.</param>
10+
/// <param name="NodeName">The accessible name of the violating node.</param>
11+
/// <param name="BackendDOMNodeId">The backend DOM node ID for element targeting, if available.</param>
12+
/// <param name="Selector">Best-effort CSS selector for the violating element, or null.</param>
13+
public sealed record AccessibilityViolation(
14+
string RuleId,
15+
AccessibilityViolationSeverity Severity,
16+
string Message,
17+
string? NodeRole,
18+
string? NodeName,
19+
long? BackendDOMNodeId,
20+
string? Selector);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Diagnostics;
2+
using Motus.Abstractions;
3+
4+
namespace Motus;
5+
6+
/// <summary>
7+
/// Walks the accessibility tree and invokes all registered rules on each node.
8+
/// </summary>
9+
internal sealed class AccessibilityRuleEngine
10+
{
11+
private readonly IReadOnlyList<IAccessibilityRule> _rules;
12+
13+
internal AccessibilityRuleEngine(IReadOnlyList<IAccessibilityRule> rules)
14+
{
15+
_rules = rules;
16+
}
17+
18+
/// <summary>
19+
/// Executes the rule engine against the provided node list and context.
20+
/// </summary>
21+
internal AccessibilityAuditResult Run(
22+
IReadOnlyList<AccessibilityNode> nodes,
23+
AccessibilityAuditContext context,
24+
string? diagnosticMessage = null)
25+
{
26+
var sw = Stopwatch.StartNew();
27+
28+
if (nodes.Count == 0 || _rules.Count == 0)
29+
{
30+
return new AccessibilityAuditResult(
31+
Violations: [],
32+
PassCount: 0,
33+
ViolationCount: 0,
34+
Duration: sw.Elapsed,
35+
DiagnosticMessage: diagnosticMessage);
36+
}
37+
38+
// Deduplication keyed on (RuleId, dedupeKey) where dedupeKey prefers
39+
// BackendDOMNodeId for stability, falling back to NodeId for virtual nodes.
40+
var seen = new HashSet<(string ruleId, string dedupeKey)>();
41+
var violations = new List<AccessibilityViolation>();
42+
int passCount = 0;
43+
44+
foreach (var node in nodes)
45+
{
46+
foreach (var rule in _rules)
47+
{
48+
var violation = rule.Evaluate(node, context);
49+
if (violation is null)
50+
{
51+
passCount++;
52+
continue;
53+
}
54+
55+
var dedupeKey = node.BackendDOMNodeId.HasValue
56+
? node.BackendDOMNodeId.Value.ToString()
57+
: node.NodeId;
58+
59+
if (seen.Add((violation.RuleId, dedupeKey)))
60+
violations.Add(violation);
61+
}
62+
}
63+
64+
return new AccessibilityAuditResult(
65+
Violations: violations,
66+
PassCount: passCount,
67+
ViolationCount: violations.Count,
68+
Duration: sw.Elapsed,
69+
DiagnosticMessage: diagnosticMessage);
70+
}
71+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
4+
namespace Motus;
5+
6+
/// <summary>
7+
/// Derives a best-effort CSS selector for an element using DOM.describeNode.
8+
/// </summary>
9+
internal static class AccessibilitySelectorHelper
10+
{
11+
/// <summary>
12+
/// Attempts to build a CSS selector for the given backend DOM node.
13+
/// Returns null if the node cannot be described. Never throws.
14+
/// </summary>
15+
internal static async Task<string?> TryGetSelectorAsync(
16+
IMotusSession session, long backendDOMNodeId, CancellationToken ct)
17+
{
18+
try
19+
{
20+
var result = await session.SendAsync(
21+
"DOM.describeNode",
22+
new DomDescribeNodeParams(BackendNodeId: (int)backendDOMNodeId),
23+
CdpJsonContext.Default.DomDescribeNodeParams,
24+
CdpJsonContext.Default.DomDescribeNodeResult,
25+
ct).ConfigureAwait(false);
26+
27+
return BuildSelector(result.Node);
28+
}
29+
catch
30+
{
31+
return null;
32+
}
33+
}
34+
35+
private static string? BuildSelector(DomNodeDescription node)
36+
{
37+
if (node.LocalName is null)
38+
return null;
39+
40+
var sb = new StringBuilder(node.LocalName.ToLowerInvariant());
41+
42+
// CDP describeNode returns attributes as a flat [name, value, name, value, ...] array.
43+
if (node.Attributes is { ValueKind: JsonValueKind.Array } attrs)
44+
{
45+
var attrArray = attrs.EnumerateArray().ToArray();
46+
for (int i = 0; i < attrArray.Length - 1; i += 2)
47+
{
48+
var name = attrArray[i].GetString();
49+
var value = attrArray[i + 1].GetString();
50+
51+
if (name == "id" && !string.IsNullOrEmpty(value))
52+
{
53+
sb.Append('#');
54+
sb.Append(value);
55+
}
56+
else if (name == "class" && !string.IsNullOrEmpty(value))
57+
{
58+
foreach (var cls in value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
59+
{
60+
sb.Append('.');
61+
sb.Append(cls);
62+
}
63+
}
64+
}
65+
}
66+
67+
return sb.ToString();
68+
}
69+
}

0 commit comments

Comments
 (0)