Skip to content

Commit 9058436

Browse files
committed
Add accessibility audits, options, and assertions
Introduce built-in accessibility support: adds AccessibilityMode and AccessibilityOptions, a new AccessibilityAuditHook plugin that can run audits after navigation or certain actions (disabled by default), and wires the hook into PluginHost. Expose LastAccessibilityAudit on Page and add Locator methods to query accessible name/role via AX tree. Add ToPassAccessibilityAuditAsync assertion and AccessibilityAssertionOptions to configure skipped rules and warning handling. Integrate accessibility settings into Motus config/JSON context and merge defaults in ConfigMerge. Update DuplicateIdCollector docs and adjust PluginHost tests to account for the added builtin; include comprehensive unit tests for the audit hook and assertions.
1 parent 1266799 commit 9058436

18 files changed

Lines changed: 782 additions & 20 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+
/// Controls how the accessibility audit hook handles violations.
5+
/// </summary>
6+
public enum AccessibilityMode
7+
{
8+
/// <summary>Accessibility auditing is disabled.</summary>
9+
Off,
10+
11+
/// <summary>Violations are reported but do not fail tests.</summary>
12+
Warn,
13+
14+
/// <summary>Error-severity violations cause test failures.</summary>
15+
Enforce
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+
/// Configuration for the built-in accessibility audit hook.
5+
/// </summary>
6+
public sealed record AccessibilityOptions
7+
{
8+
/// <summary>Whether the accessibility audit hook is enabled. Default: false.</summary>
9+
public bool Enable { get; init; }
10+
11+
/// <summary>How violations are handled. Default: Enforce.</summary>
12+
public AccessibilityMode Mode { get; init; } = AccessibilityMode.Enforce;
13+
14+
/// <summary>Whether to run an audit after each navigation. Default: true.</summary>
15+
public bool AuditAfterNavigation { get; init; } = true;
16+
17+
/// <summary>
18+
/// Whether to run an audit after mutating actions (click, fill, selectOption).
19+
/// Default: false.
20+
/// </summary>
21+
public bool AuditAfterActions { get; init; }
22+
23+
/// <summary>Whether warnings count as failures alongside errors. Default: true.</summary>
24+
public bool IncludeWarnings { get; init; } = true;
25+
26+
/// <summary>Rule IDs to exclude from audits (e.g., "a11y-color-contrast").</summary>
27+
public IReadOnlyList<string>? SkipRules { get; init; }
28+
}

src/Motus.Abstractions/Options/LaunchOptions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ public sealed record LaunchOptions
4040

4141
/// <summary>Manually registered plugins to load into each browser context.</summary>
4242
public IReadOnlyList<IPlugin>? Plugins { get; init; }
43+
44+
/// <summary>Accessibility audit hook configuration. Disabled by default.</summary>
45+
public AccessibilityOptions? Accessibility { get; init; }
4346
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using Motus.Abstractions;
2+
3+
namespace Motus;
4+
5+
/// <summary>
6+
/// Built-in plugin that runs accessibility audits after navigation and (optionally)
7+
/// after mutating actions. Disabled by default; enabled via <see cref="AccessibilityOptions"/>.
8+
/// </summary>
9+
internal sealed class AccessibilityAuditHook : IPlugin, ILifecycleHook
10+
{
11+
private static readonly HashSet<string> AuditedActions = new(StringComparer.Ordinal)
12+
{
13+
"click", "fill", "selectOption"
14+
};
15+
16+
private readonly AccessibilityOptions _options;
17+
private BrowserContext? _context;
18+
19+
internal AccessibilityAuditHook(AccessibilityOptions? options)
20+
{
21+
_options = options ?? new AccessibilityOptions();
22+
}
23+
24+
public string PluginId => "motus.accessibility-audit";
25+
public string Name => "Accessibility Audit Hook";
26+
public string Version => "1.0.0";
27+
public string? Author => "Motus";
28+
public string? Description => "Runs WCAG accessibility audits after navigation and actions.";
29+
30+
public Task OnLoadedAsync(IPluginContext context)
31+
{
32+
if (!_options.Enable)
33+
return Task.CompletedTask;
34+
35+
_context = ((PluginContext)context).Context;
36+
context.RegisterLifecycleHook(this);
37+
return Task.CompletedTask;
38+
}
39+
40+
public Task OnUnloadedAsync() => Task.CompletedTask;
41+
42+
public Task BeforeNavigationAsync(IPage page, string url) => Task.CompletedTask;
43+
44+
public async Task AfterNavigationAsync(IPage page, IResponse? response)
45+
{
46+
if (!_options.AuditAfterNavigation || _context is null)
47+
return;
48+
49+
await RunAuditAsync(page).ConfigureAwait(false);
50+
}
51+
52+
public Task BeforeActionAsync(IPage page, string action) => Task.CompletedTask;
53+
54+
public async Task AfterActionAsync(IPage page, string action, ActionResult result)
55+
{
56+
if (!_options.AuditAfterActions || _context is null || !AuditedActions.Contains(action))
57+
return;
58+
59+
await RunAuditAsync(page).ConfigureAwait(false);
60+
}
61+
62+
public Task OnPageCreatedAsync(IPage page) => Task.CompletedTask;
63+
public Task OnPageClosedAsync(IPage page) => Task.CompletedTask;
64+
public Task OnConsoleMessageAsync(IPage page, ConsoleMessageEventArgs message) => Task.CompletedTask;
65+
public Task OnPageErrorAsync(IPage page, PageErrorEventArgs error) => Task.CompletedTask;
66+
67+
private async Task RunAuditAsync(IPage page)
68+
{
69+
var concrete = (Page)page;
70+
var rules = FilterRules(_context!.AccessibilityRules.Snapshot());
71+
var result = await concrete.RunAccessibilityAuditAsync(rules, CancellationToken.None)
72+
.ConfigureAwait(false);
73+
concrete.LastAccessibilityAudit = result;
74+
}
75+
76+
private IReadOnlyList<IAccessibilityRule> FilterRules(IReadOnlyList<IAccessibilityRule> rules)
77+
{
78+
var skip = _options.SkipRules;
79+
if (skip is null or { Count: 0 })
80+
return rules;
81+
82+
var skipSet = new HashSet<string>(skip, StringComparer.Ordinal);
83+
return rules.Where(r => !skipSet.Contains(r.RuleId)).ToList();
84+
}
85+
}

src/Motus/Accessibility/DuplicateIdCollector.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
namespace Motus;
22

33
/// <summary>
4-
/// Pre-fetches the set of duplicate HTML id values in the document via DOM domain queries.
4+
/// Pre-fetches the set of duplicate HTML id values in the document.
5+
///
6+
/// Uses Runtime.evaluate with a JS snippet rather than DOM.getDocument + DOM.querySelectorAll
7+
/// CDP commands, which reduces the interaction to a single CDP round-trip rather than three
8+
/// or more (getDocument, querySelectorAll, then getAttributes per matched node).
9+
///
10+
/// Risk: If page-level JS execution is restricted (e.g., certain CSP configurations), this
11+
/// will silently return no duplicates. The DOM domain commands operate at the protocol level
12+
/// and are unaffected by page JS restrictions. In practice the risk is low because Motus
13+
/// controls the browser instance and JS execution is always available.
514
/// </summary>
615
internal static class DuplicateIdCollector
716
{
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Motus.Assertions;
2+
3+
/// <summary>
4+
/// Configuration for the <c>ToPassAccessibilityAuditAsync</c> assertion.
5+
/// </summary>
6+
public sealed class AccessibilityAssertionOptions
7+
{
8+
private readonly List<string> _skip = [];
9+
10+
/// <summary>
11+
/// Excludes the specified rule IDs from the pass/fail evaluation.
12+
/// </summary>
13+
public AccessibilityAssertionOptions SkipRules(params string[] ruleIds)
14+
{
15+
_skip.AddRange(ruleIds);
16+
return this;
17+
}
18+
19+
/// <summary>Whether warnings count as failures alongside errors. Default: true.</summary>
20+
public bool IncludeWarnings { get; set; } = true;
21+
22+
internal IReadOnlyList<string> SkippedRules => _skip;
23+
}

src/Motus/Assertions/LocatorAssertions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,18 @@ public Task ToHaveCountAsync(int expected, AssertionOptions? options = null) =>
157157
var count = await _locator.CountAsync().ConfigureAwait(false);
158158
return (count == expected, count.ToString());
159159
}, "ToHaveCount", expected.ToString(), options);
160+
161+
public Task ToHaveAccessibleNameAsync(string expected, AssertionOptions? options = null) =>
162+
RetryAsync(async ct =>
163+
{
164+
var name = await _locator.GetAccessibilityNameAsync(ct).ConfigureAwait(false) ?? "";
165+
return (name == expected, name);
166+
}, "ToHaveAccessibleName", expected, options);
167+
168+
public Task ToHaveRoleAsync(string expected, AssertionOptions? options = null) =>
169+
RetryAsync(async ct =>
170+
{
171+
var role = await _locator.GetAccessibilityRoleAsync(ct).ConfigureAwait(false) ?? "";
172+
return (role == expected, role);
173+
}, "ToHaveRole", expected, options);
160174
}

src/Motus/Assertions/PageAssertions.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,92 @@ public Task ToHaveTitleAsync(string expected, AssertionOptions? options = null)
3838
var title = await _page.TitleAsync().ConfigureAwait(false);
3939
return (title == expected, title);
4040
}, "ToHaveTitle", expected, options);
41+
42+
public async Task ToPassAccessibilityAuditAsync(
43+
Action<AccessibilityAssertionOptions>? configure = null,
44+
AssertionOptions? options = null)
45+
{
46+
var a11yOptions = new AccessibilityAssertionOptions();
47+
configure?.Invoke(a11yOptions);
48+
49+
var result = _page.LastAccessibilityAudit;
50+
if (result is null)
51+
{
52+
// On-demand audit: no hook stored a result, so run one now
53+
var rules = FilterRules(
54+
_page.ContextInternal.AccessibilityRules.Snapshot(),
55+
a11yOptions.SkippedRules);
56+
result = await _page.RunAccessibilityAuditAsync(rules, CancellationToken.None)
57+
.ConfigureAwait(false);
58+
}
59+
60+
var violations = FilterViolations(result.Violations, a11yOptions);
61+
var hasViolations = violations.Count > 0;
62+
var passed = _negate ? hasViolations : !hasViolations;
63+
64+
if (!passed)
65+
{
66+
var negateLabel = _negate ? "NOT " : "";
67+
var expected = $"{negateLabel}0 accessibility violations";
68+
var actual = FormatViolations(violations);
69+
var message = options?.Message
70+
?? $"Assertion {negateLabel}ToPassAccessibilityAudit failed."
71+
+ $" Expected: {expected}. Found {violations.Count} violation(s)."
72+
+ (_page.Url is not null ? $" Page: {_page.Url}." : "");
73+
74+
throw new MotusAssertionException(
75+
expected: expected,
76+
actual: actual,
77+
selector: null,
78+
pageUrl: _page.Url,
79+
assertionTimeout: TimeSpan.Zero,
80+
message: message);
81+
}
82+
}
83+
84+
private static IReadOnlyList<IAccessibilityRule> FilterRules(
85+
IReadOnlyList<IAccessibilityRule> rules, IReadOnlyList<string> skippedRules)
86+
{
87+
if (skippedRules.Count == 0)
88+
return rules;
89+
90+
var skipSet = new HashSet<string>(skippedRules, StringComparer.Ordinal);
91+
return rules.Where(r => !skipSet.Contains(r.RuleId)).ToList();
92+
}
93+
94+
private static IReadOnlyList<AccessibilityViolation> FilterViolations(
95+
IReadOnlyList<AccessibilityViolation> violations,
96+
AccessibilityAssertionOptions options)
97+
{
98+
var filtered = violations.AsEnumerable();
99+
100+
if (options.SkippedRules.Count > 0)
101+
{
102+
var skipSet = new HashSet<string>(options.SkippedRules, StringComparer.Ordinal);
103+
filtered = filtered.Where(v => !skipSet.Contains(v.RuleId));
104+
}
105+
106+
if (!options.IncludeWarnings)
107+
filtered = filtered.Where(v => v.Severity == AccessibilityViolationSeverity.Error);
108+
else
109+
filtered = filtered.Where(v =>
110+
v.Severity is AccessibilityViolationSeverity.Error
111+
or AccessibilityViolationSeverity.Warning);
112+
113+
return filtered.ToList();
114+
}
115+
116+
private static string FormatViolations(IReadOnlyList<AccessibilityViolation> violations)
117+
{
118+
var sb = new System.Text.StringBuilder();
119+
sb.AppendLine($"Accessibility audit failed with {violations.Count} violation(s):");
120+
foreach (var v in violations)
121+
{
122+
sb.Append($" [{v.Severity}] {v.RuleId}: {v.Message}");
123+
if (v.Selector is not null)
124+
sb.Append($" (selector: {v.Selector})");
125+
sb.AppendLine();
126+
}
127+
return sb.ToString().TrimEnd();
128+
}
41129
}

src/Motus/Config/ConfigMerge.cs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,45 @@ internal static class ConfigMerge
66
{
77
internal static LaunchOptions ApplyConfig(LaunchOptions options)
88
{
9+
var result = options;
10+
911
var launch = MotusConfigLoader.Config.Launch;
10-
if (launch is null)
11-
return options;
12+
if (launch is not null)
13+
{
14+
if (options.Headless && launch.Headless.HasValue)
15+
result = result with { Headless = launch.Headless.Value };
1216

13-
var result = options;
17+
if (options.Channel is null && launch.Channel is not null
18+
&& Enum.TryParse<BrowserChannel>(launch.Channel, ignoreCase: true, out var channel))
19+
result = result with { Channel = channel };
1420

15-
if (options.Headless && launch.Headless.HasValue)
16-
result = result with { Headless = launch.Headless.Value };
21+
if (options.SlowMo == 0 && launch.SlowMo.HasValue)
22+
result = result with { SlowMo = launch.SlowMo.Value };
1723

18-
if (options.Channel is null && launch.Channel is not null
19-
&& Enum.TryParse<BrowserChannel>(launch.Channel, ignoreCase: true, out var channel))
20-
result = result with { Channel = channel };
24+
if (options.Timeout == 30_000 && launch.Timeout.HasValue)
25+
result = result with { Timeout = launch.Timeout.Value };
26+
}
2127

22-
if (options.SlowMo == 0 && launch.SlowMo.HasValue)
23-
result = result with { SlowMo = launch.SlowMo.Value };
28+
var a11y = MotusConfigLoader.Config.Accessibility;
29+
if (a11y is not null && options.Accessibility is null)
30+
{
31+
var mode = AccessibilityMode.Enforce;
32+
if (a11y.Mode is not null)
33+
Enum.TryParse(a11y.Mode, ignoreCase: true, out mode);
2434

25-
if (options.Timeout == 30_000 && launch.Timeout.HasValue)
26-
result = result with { Timeout = launch.Timeout.Value };
35+
result = result with
36+
{
37+
Accessibility = new AccessibilityOptions
38+
{
39+
Enable = a11y.Enable ?? false,
40+
Mode = mode,
41+
AuditAfterNavigation = a11y.AuditAfterNavigation ?? true,
42+
AuditAfterActions = a11y.AuditAfterActions ?? false,
43+
IncludeWarnings = a11y.IncludeWarnings ?? true,
44+
SkipRules = a11y.SkipRules,
45+
}
46+
};
47+
}
2748

2849
return result;
2950
}

0 commit comments

Comments
 (0)