MotusFeature

Accessibility and performance checks, in the test you already wrote

Motus checks the page your test is already driving against nine WCAG rules and collects Core Web Vitals while the run happens, so both become ordinary assertions and ordinary CI gates instead of a second tool.

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.

GotoAsync(url) ClickAsync() Expect.That(page) hook fires hook fires reads the result One page session Accessibility Accessibility.getFullAXTree nine rules, once per node AccessibilityViolation list Performance PerformanceObserver (injected) Performance.getMetrics PerformanceMetrics record One test result
Where an audit runs. The hook fires after a navigation and after a click, fill or select, and the assertion reads what it stored.

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.