Motus
Extensible browser automation and testing framework for .NET, built on a fully extensible architecture.
The Story
Every .NET browser automation framework either wraps a JavaScript tool behind a process boundary or bolts extensibility on as an afterthought. Playwright for .NET proxies commands through a Node.js sidecar. Selenium's extension model grew organically over fifteen years and it shows.
Motus started from a premise proven by the architecture of Verso: if the framework's own features are built on the same public plugin interfaces available to third-party authors, the architecture stays honest. Every built-in selector strategy, lifecycle hook, wait condition, and reporter is registered through the same IPluginContext that any consumer can use. There are no internal shortcuts.
The result is a framework that talks directly to Chromium and Firefox (CDP and WebDriver BiDi), ships source-generated protocol bindings for NativeAOT, and gives you compile-time diagnostics for common automation mistakes before your tests ever run.
Getting Started
Prerequisites
- .NET 8 SDK or .NET 10 SDK
- Chrome, Edge, or Firefox installed (or use
motus installto download a browser binary)
Install
dotnet add package Motus
dotnet add package Motus.Testing.MSTest.v4 # MSTest 4; or Motus.Testing.MSTest (MSTest 3) / Motus.Testing.xUnit / Motus.Testing.NUnit
dotnet tool install --global Motus.Cli
Write Your First Test
using Motus;
using Motus.Abstractions;
using Motus.Testing.MSTest;
using static Motus.Assertions.Expect;
// [MotusTestClass] stands in for [TestClass] and re-runs any test whose browser
// disconnects underneath it. Failed assertions are never re-run.
[MotusTestClass]
public class SearchTests : MotusTestBase
{
[TestMethod]
public async Task SearchReturnsResults()
{
var page = await Context.NewPageAsync();
await page.GotoAsync("https://example.com");
await page.Locator("input[name='q']").FillAsync("motus automation");
await page.Locator("button[type='submit']").ClickAsync();
await That(page).ToHaveUrlAsync("**/search**");
await That(page.Locator(".results")).ToBeVisibleAsync();
}
}
Generate Page Objects from Live Pages
motus codegen https://example.com/login --output ./Pages --namespace MyApp.Pages
# Or open a browser, navigate yourself, then press Enter to analyze
motus codegen --headed --output ./Pages
This navigates to the URL, crawls the DOM for interactive elements, infers the best selector for each using pluggable strategies, and emits a typed .g.cs Page Object Model class. Use --detect-listeners to also discover elements with directly-attached JS event handlers (vanilla JS, jQuery, etc.).
Record a Test Session
motus record --output ./Tests/LoginTest.cs --framework mstest --selector-priority testid,role,text,css
Run Tests
motus run ./bin/Debug/net8.0/MyTests.dll --workers auto --reporter console
motus run ./bin/Debug/net8.0/MyTests.dll --visual
motus run ./bin/Debug/net8.0/MyTests.dll --reporter html:./reports/result.html
motus run ./bin/Debug/net8.0/MyTests.dll --reporter junit:./reports/junit.xml
Accessibility Testing
Motus audits pages against WCAG 2.1 Level A/AA rules using the browser's native accessibility tree (no JavaScript injection). Enable the built-in audit hook to run checks automatically after every navigation, or use assertions for targeted validation.
// Assert the page passes all accessibility rules
await Expect.That(page).ToPassAccessibilityAuditAsync();
// Skip specific rules or exclude warnings
await Expect.That(page).ToPassAccessibilityAuditAsync(opts =>
{
opts.SkipRules("a11y-color-contrast");
opts.IncludeWarnings = false;
});
// Assert accessible name and role on individual elements
await Expect.That(page.Locator("button#submit")).ToHaveAccessibleNameAsync("Submit order");
await Expect.That(page.Locator("nav")).ToHaveRoleAsync("navigation");
Enable the audit lifecycle hook via config or CLI to run audits automatically:
# Report violations without failing tests
motus run ./bin/Debug/net8.0/MyTests.dll --a11y warn
# Fail tests on error-severity violations
motus run ./bin/Debug/net8.0/MyTests.dll --a11y enforce
Or in motus.config.json:
{
"accessibility": {
"enable": true,
"mode": "enforce",
"skipRules": ["a11y-color-contrast"]
}
}
Performance Testing
Motus collects Core Web Vitals (LCP, FCP, TTFB, CLS, INP) and supplementary metrics (JS heap size, DOM node count) directly from the browser during test execution. Set budget thresholds via attributes or config, and performance regressions fail your tests automatically.
// Assert all metrics are within the class-level budget
[PerformanceBudget(Lcp = 2500, Fcp = 1800, Cls = 0.1)]
[TestClass]
public class DashboardTests : MotusTestBase
{
[TestMethod]
public async Task DashboardLoadsWithinBudget()
{
await Page.GotoAsync("https://app.example.com/dashboard");
await Expect.That(Page).ToMeetPerformanceBudgetAsync();
}
}
// Or assert individual metrics
await Expect.That(page).ToHaveLcpBelowAsync(2500);
await Expect.That(page).ToHaveClsBelowAsync(0.1);
Enable budget enforcement from the CLI:
motus run ./bin/Debug/net8.0/MyTests.dll --perf-budget
Or in motus.config.json:
{
"performance": {
"enable": true,
"lcp": 2500,
"cls": 0.1,
"inp": 200
}
}
Code Coverage
Motus collects JavaScript coverage via the CDP Profiler/Debugger domains and CSS rule-usage coverage via the CDP CSS domain on every page driven by your tests. Coverage is collected per-test and aggregated across the run, with optional thresholds that fail the run when not met.
Enable coverage from the CLI with one or more output formats:
# Console summary table
motus run ./bin/Debug/net8.0/MyTests.dll --coverage console
# Static HTML report (index + per-file source views with line highlighting)
motus run ./bin/Debug/net8.0/MyTests.dll --coverage html:./coverage
# Cobertura XML for CI ingestion
motus run ./bin/Debug/net8.0/MyTests.dll --coverage cobertura:./coverage.xml
# Repeat the flag for multiple formats at once
motus run ./bin/Debug/net8.0/MyTests.dll --coverage console --coverage html:./coverage
Source-mapped JS bundles are remapped back to original sources automatically when sourcemaps are reachable. Configure thresholds and defaults in motus.config.json:
{
"coverage": {
"enable": true,
"includeJavaScript": true,
"includeCss": true,
"js": { "lines": 70, "functions": 60 },
"css": { "rules": 50 }
}
}
When a threshold is set and the aggregated run coverage falls below it, the run exits non-zero so CI fails the build.
Retrying Flaky Runs
The browser connection is occasionally dropped under heavy tracing or when sibling targets close mid-command. Use --retries N to re-run a failing test up to N additional times, but only when the failure is a transient disconnect. Non-transient failures (assertion errors, timeouts) are not retried, so real bugs aren't masked. Pass --retry-policy flake to re-run any failure instead.
motus run ./bin/Debug/net8.0/MyTests.dll --retries 2
Each retry runs the entire test fresh: new browser context, new browser connection, new test instance. A [RETRY] line is logged to stderr for every attempt so flake patterns are visible.
Under dotnet test, [MotusTestClass] does the same for an MSTest suite: it re-runs a test whose browser disconnected, and never one that failed an assertion.
Pinning the Browser
A suite is only reproducible if it can say which browser it ran against. A CI image update can change Chrome underneath a build that was green yesterday, and the failure that follows is indistinguishable from a regression in your own code.
motus install --revision downloads an exact Chrome for Testing build, and a test run that names no browser uses it, ahead of any Chrome installed on the machine. MOTUS_EXECUTABLE_PATH names the binary explicitly, and code that sets LaunchOptions.ExecutablePath itself still wins over both.
motus install --channel chromium --revision 149.0.7827.156
dotnet test
# Or name the binary explicitly
export MOTUS_EXECUTABLE_PATH="$(cat ~/.motus/browsers/.installed.chromium)"
dotnet test
Or in motus.config.json:
{
"launch": {
"executablePath": "/opt/chrome-149/chrome"
}
}
On Windows, motus install also grants the browser's directory the read and execute access Chromium's sandbox needs, which an installed browser is given by its own installer and a downloaded one is not. Without it the browser still starts, but the first child process it sandboxes is denied access to the executable and has to be replaced, and it reports that on every launch. Running the install again over a copy you already have applies the grant to it.
MCP Server for AI Agents
Motus exposes its browser engine to AI agents through a Model Context Protocol server, shipped as the motus mcp verb on the CLI tool. Agents navigate, snapshot the accessibility tree, click and type against referenced elements, read the console and network logs, run accessibility and performance audits, and generate Page Object Model code, all over stdio or Streamable HTTP. Coordinate input on canvas surfaces (including drag and drop), request interception, isolated contexts, and recording traces, HARs, and videos are named with --caps, so a catalog only carries what a session needs. Start the server with --allow-attach or --connect and an agent can also drive a browser that is already running, which stays the operator's call because that browser may hold somebody's signed-in sessions.
Register it with Claude Code against the installed tool. The plain command carries the 34 always-available tools; add --caps for the rest, which is the form a developer working on a product usually wants:
dotnet tool install --global Motus.Cli
motus install
claude mcp add motus -- motus mcp --caps coordinates,recording,contexts,routing
See MCP Server for the full registration story, the tool catalog, and the HTTP transport.
How It Works
Motus communicates directly with the browser. For Chromium-based browsers, it speaks the Chrome DevTools Protocol (CDP). For Firefox, it uses WebDriver BiDi. There is no Node.js sidecar, no driver binary, and no process boundary between your test code and the protocol layer.
A Chromium browser Motus starts on macOS or Linux is driven over a pipe rather than a debugging port, so the browser exits when the process that started it does, even if that process is killed outright. Everything else, including a browser you attach to, uses a WebSocket.
All CDP types are source-generated at build time from the protocol JSON schema. Serialization uses System.Text.Json source generators for zero-reflection, NativeAOT-compatible marshalling.
Architecture
The Extension Model
Every point of extensibility is an interface registered through IPluginContext:
Plugin Discovery
Plugins are discovered two ways:
- Compile-time auto-discovery: Mark a class with
[MotusPlugin]and the Roslyn source generator emits a[ModuleInitializer]that registers it at startup. No reflection. - Manual registration: Pass instances via
LaunchOptions.Pluginsfor full control.
[MotusPlugin]
public class MyPlugin : IPlugin
{
public string PluginId => "my-plugin";
public string Name => "My Plugin";
public string Version => "1.0.0";
public Task OnLoadedAsync(IPluginContext context)
{
context.RegisterSelectorStrategy(new MyCustomStrategy());
context.RegisterLifecycleHook(new MyHook());
return Task.CompletedTask;
}
public Task OnUnloadedAsync() => Task.CompletedTask;
}
Dogfooding All the Way Down
The five built-in selector strategies (CSS, XPath, Text, Role, TestId) are registered through IPluginContext. The console, HTML, JUnit, and TRX reporters implement IReporter. The visual runner's timeline is powered by an ILifecycleHook. The nine built-in WCAG rules and the accessibility audit hook are registered as plugins through the same IPluginContext. None of them have special access to engine internals.
Roslyn Analyzers
The Motus.Analyzers package ships seven diagnostics that catch common automation mistakes at compile time:
Code fixes are provided for MOT001, MOT002, and MOT004.
Visual Runner
Launch the Blazor-based visual runner with motus run --visual:
- Live browser view with real-time screencast via CDP
Page.startScreencast - Action timeline with clickable steps, before/after screenshots, network requests, and console messages
- Step-through debugging with pause, inspect, and resume controls
- Visual regression with pixel-level diff, side-by-side comparison, and baseline management
Key Features
CLI Reference
motus run <assemblies> Run tests with optional --visual, --filter, --workers, --reporter, --a11y, --perf-budget, --coverage, --retries, --retry-policy, --quarantine, --shard
motus record Record a browser session and emit test code
motus codegen Generate POM classes from live pages (--headed, --connect, --detect-listeners)
motus check-selectors Validate recorded selectors against live pages (--base-url, --manifest, --fix, --ci)
motus screenshot Capture a screenshot (--full-page, --delay, --hide-banners, --width, --height)
motus pdf Generate a PDF from a URL (--delay, --hide-banners, --width, --timeout)
motus trace show Open a trace file in the visual runner with timeline, screenshots, and network
motus trx show Open a TRX result file in the visual runner
motus shard merge Merge per-shard result files into one report (--output, --expect)
motus install Download and install browser binaries
motus update-protocol Fetch and update CDP protocol schema files
motus mcp Run the MCP server for AI agents (stdio by default, or --http)
Build from Source
git clone https://github.com/DataficationSDK/Motus
cd Motus
dotnet build Motus.sln
dotnet test Motus.sln
Contributing
Contributions are welcome. Open an issue to discuss what you'd like to work on.
License
Motus is a Datafication project.