If you've ever gotten a clean accessibility report out of CI and later found out it had been scanning your login screen, you know the problem. Accessibility and performance usually get checked by a second tool, driving a second browser session, in its own stage of the pipeline, and that tool sees a different page than your test does. It gets the empty state instead of the one with the record that reproduces the bug, because it does not know how to get there and your test suite does.
Motus 1.0.7 added accessibility auditing and 1.0.8, out today, adds Core Web Vitals and performance budgets. Both read from the session your test is already driving. An audit is an assertion in the middle of a test, not a job at the end of the build.
The accessibility side
The tree comes from the browser, not from a library we inject into your page. Motus asks CDP for the browser's own computed accessibility tree and hands each node to every registered rule, and color values come from the browser's computed styles. Two of the nine rules do run a single Runtime.evaluate each, for the duplicate-id sweep and for the document's lang attribute, so the honest way to put this is that the audit does not inject a rule engine, not that it runs no JavaScript at all.
Nine rules ship, registered by AccessibilityRulesPlugin:
| Rule id | Severity | What it catches |
|---|---|---|
a11y-alt-text |
Error | An image with no accessible name |
a11y-empty-button |
Error | A button with no accessible name |
a11y-empty-link |
Error | A link with no accessible name |
a11y-unlabeled-form-control |
Error | A form control with no label, aria-label or aria-labelledby |
a11y-color-contrast |
Error | Text under 4.5:1, or 3:1 for large text |
a11y-duplicate-id |
Error | An id value used more than once in the document |
a11y-missing-lang |
Error | An <html> element with no lang attribute |
a11y-missing-landmark |
Warning | A page with no main landmark |
a11y-heading-hierarchy |
Warning | A heading level that skips, such as h1 to h3 |
Nine rules is a small number, and I mean it to read that way. axe-core's own published rule set is several times larger, and Motus does not depend on it, port it, or try to replace it. What nine rules buy you is that the check is already in the box, runs in-process, and has no extra package to install and keep current. Playwright delegates accessibility auditing to the separate axe-playwright ecosystem package, and if you need axe's full coverage that is still the right tool.
Three ways to use it
As an assertion, on the page you are already on:
await Expect.That(Page).ToPassAccessibilityAuditAsync();
await Expect.That(Page).ToPassAccessibilityAuditAsync(opts =>
{
opts.SkipRules("a11y-color-contrast", "a11y-missing-landmark");
});
IncludeWarnings defaults to true, so the two warning-severity rules fail the assertion unless you set it to false. Individual elements have their own assertions: ToHaveAccessibleNameAsync("Submit order") and ToHaveRoleAsync("navigation").
A failure names what it found. Each AccessibilityViolation carries the rule id, the severity, a message, the node's role and accessible name, and the backend node id it came from, so the report points at an element rather than at a page. Worth knowing: unlike most locator assertions, the audit assertion evaluates once instead of polling, which is the right behavior for a whole-page check. Put it after the assertion that establishes the state you meant to audit.
As a lifecycle hook, so every navigation is audited without a line in the test:
await MotusTestBase.LaunchBrowserAsync(new LaunchOptions
{
Headless = true,
Accessibility = new AccessibilityOptions
{
Enable = true,
Mode = AccessibilityMode.Warn,
AuditAfterActions = true
},
Performance = new PerformanceOptions { Enable = true }
});
AuditAfterNavigation is on by default and AuditAfterActions is off; when it is on, the audited actions are click, fill and select. Mode is Off, Warn or Enforce. When the hook is running, ToPassAccessibilityAuditAsync reuses the audit it already stored rather than running another one; when the hook is off, the assertion runs an audit on demand.
And from the runner:
motus run ./bin/Debug/net8.0/MyTests.dll --a11y enforce --perf-budget
One thing to know about --a11y: leaving it off means the hook never activates, whatever motus.config.json says. The flag is what turns the section on.
Adding a rule of your own
IAccessibilityRule has three members, and a rule is called once per node with a context that carries the page-wide facts a single node cannot answer.
using Motus.Abstractions;
public sealed class DisclosureExpandedRule : IAccessibilityRule
{
public string RuleId => "custom-aria-expanded-disclosure";
public string Description =>
"Disclosure triggers must expose aria-expanded.";
public AccessibilityViolation? Evaluate(
AccessibilityNode node, AccessibilityAuditContext context)
{
if (!string.Equals(node.Role, "button", StringComparison.OrdinalIgnoreCase))
return null;
if (!node.Properties.ContainsKey("controls") || node.Properties.ContainsKey("expanded"))
return null;
return new AccessibilityViolation(
RuleId: RuleId,
Severity: AccessibilityViolationSeverity.Error,
Message: "Disclosure button must expose aria-expanded.",
NodeRole: node.Role,
NodeName: node.Name,
BackendDOMNodeId: node.BackendDOMNodeId,
Selector: null);
}
}
Register it from a plugin with context.RegisterAccessibilityRule(...), which is the same call the nine built-in rules use. That is not a coincidence: as we wrote about the plugin model, the built-ins have no private entrance.
The performance side
The performance collector is a lifecycle hook too. It installs a small observer script when the page is created, takes a snapshot after each navigation, and takes a final one when the page closes.
| Metric | Where the value comes from | Unit |
|---|---|---|
| LCP | injected PerformanceObserver on largest-contentful-paint |
ms |
| FCP | injected PerformanceObserver on paint, with a CDP fallback |
ms |
| CLS | injected PerformanceObserver on layout-shift |
score |
| INP | injected PerformanceObserver on event |
ms |
| TTFB | a Navigation Timing read: responseStart - startTime |
ms |
| JS heap size | CDP Performance.getMetrics |
bytes |
| DOM node count | CDP Performance.getMetrics |
count |
A metric that was never observed stays null, and an assertion against a null metric fails immediately rather than passing quietly.
Thresholds are declared, not passed in:
[TestClass]
[PerformanceBudget(Lcp = 2500, Fcp = 1800, Cls = 0.1)]
public class DashboardTests : MotusTestBase
{
[TestMethod]
public async Task DashboardIsWithinBudget()
{
await Page.GotoAsync("https://app.example.com/dashboard");
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
}
[TestMethod]
[PerformanceBudget(Lcp = 1500)]
public async Task CheckoutIsTighter()
{
await Page.GotoAsync("https://app.example.com/checkout");
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
}
}
Every property on the attribute defaults to -1, which means not enforced, so a budget only covers the metrics you name. Zero is a real threshold. The method attribute wins over the class attribute, and motus.config.json is the fallback. If nothing supplies a budget at all, ToMeetPerformanceBudgetAsync throws instead of passing, so a test can never quietly assert nothing.
Individual metrics have their own assertions when a whole budget is more than you want: ToHaveLcpBelowAsync(2500), ToHaveFcpBelowAsync(1800), ToHaveTtfbBelowAsync(600), ToHaveClsBelowAsync(0.1), ToHaveInpBelowAsync(200). Each one re-collects metrics on every poll, so a value that arrives late still counts.
One more thing about the CLI flag: --perf-budget turns enforcement on and nothing else. The numbers still come from [PerformanceBudget] or from the performance section of the config file.
What Firefox gets
The observer script is ordinary page JavaScript, so LCP, FCP, CLS and INP are collected over WebDriver BiDi as well. JS heap size and DOM node count come from a CDP domain and are always null there. The accessibility tree is CDP-only too, so an audit on a Firefox session comes back empty with a diagnostic message naming the transport rather than throwing halfway through a suite. If accessibility gating matters to you, run that job on Chromium.
None of this says the browser you test in is the browser your users have. It says something narrower, about which page gets checked. The audit that runs after your test has logged in, opened the drawer and filtered the table is looking at the markup a person would actually run into, and the budget it is measured against is the one your team wrote down.
Both features are off by default. Turning them on is one options block or one flag, and after that the checks live where the rest of your assertions do. The accessibility guide and the performance guide cover the rest of the configuration. If you turn it on and there is a rule you wish was in the box, tell me which one.