Browser suites rot from the outside in. The assertions stay true and the selectors quietly stop matching, because somebody renamed a CSS class or moved a button into a different container. The test goes red for a reason that has nothing to do with what it was protecting. If you have ever spent a morning chasing that down, you know how it feels.
Motus 1.0.10 closes the loop. The recorder and the code generator now write a selector manifest beside the code they emit, and motus check-selectors reads it, checks every selector against a live page, and rewrites the broken ones it is sure about.
Before you start
You need the .NET 8 SDK or later, a test project, and the CLI. One thing to get right up front: the verb is motus, never dotnet motus.
dotnet add package Motus
dotnet add package Motus.Testing.MSTest
dotnet tool install --global Motus.Cli
motus install
motus install pulls down a Chrome for Testing build into ~/.motus/browsers. Every example below drives an application on http://localhost:5000 that has a top bar and a sign-in form:
<nav class="topbar"><button class="link-btn">Sign in</button></nav>
<form>
<input data-testid="email" type="email" aria-label="Email">
<input data-testid="password" type="password" aria-label="Password">
<button data-testid="sign-in" type="submit">Sign in</button>
</form>
Step 1: generate the page objects
Start with motus codegen. It loads a page, waits for the network to settle, crawls the DOM for interactive elements (inputs, buttons, selects, textareas, links with an href, and anything with role="button"), infers a selector for each one, and writes a partial class.
motus codegen http://localhost:5000/sign-in \
--output tests/Pages \
--namespace MyApp.Tests.Pages
The class name comes from the URL: host segments first, with www dropped, then path segments. So that command writes tests/Pages/LocalhostSignInPage.g.cs:
// <auto-generated/>
using Motus.Abstractions;
namespace MyApp.Tests.Pages;
public partial class LocalhostSignInPage
{
private readonly IPage _page;
public LocalhostSignInPage(IPage page) => _page = page;
public Task NavigateAsync() => _page.GotoAsync("http://localhost:5000/sign-in");
// Locators
public ILocator EmailInput => _page.Locator("data-testid=email");
public ILocator PasswordInput => _page.Locator("data-testid=password");
public ILocator SignInButton => _page.Locator("data-testid=sign-in");
// Form actions
public async Task SubmitSignInFormAsync(string email, string password)
{
await EmailInput.FillAsync(email);
await PasswordInput.FillAsync(password);
await SignInButton.ClickAsync();
}
}
Two things about that file. It is partial, so your own helpers live in a second file that regeneration never touches. And the property names come from the first of id, name, aria-label, placeholder and visible text that is actually set, plus a suffix for the kind of element. SubmitSignInFormAsync shows up only because the form holds a fillable input and a submit button.
If you cannot reach the page by URL, there are three ways in. --headed opens a visible browser and waits for you to press Enter. --connect ws://localhost:9222 analyzes what is already open in a browser you started yourself. And --scope "#login-form" limits discovery to a single container.
How a selector gets chosen
This part is worth a minute of your time, because it explains the code you get back. Inference walks the registered selector strategies in priority order and takes the first candidate that is at most 200 characters and resolves to exactly one node. These are the same ISelectorStrategy implementations the runtime uses, registered the way any other plugin is, so a custom strategy changes generation and resolution together.
| Strategy | Priority | What it emits |
|---|---|---|
_node |
100 | Nothing. Backend node ids are ephemeral |
data-testid |
40 | data-testid=sign-in |
role |
30 | role=button[name="Sign in"] |
text |
20 | text=Sign in |
css |
10 | #sign-in, or css=nav.topbar > button.link-btn |
xpath |
10 | xpath=/html/body/nav[1]/button[1] |
In practice the ladder is test id, role, text, CSS. CSS sits ahead of XPath at the same priority and almost always succeeds, so XPath rarely wins. When nothing qualifies, the element comes out as a // TODO: comment instead of a guessed locator. I would rather hand you a gap you can see than a locator that looks fine and is not.
Step 2: record a scenario
motus record launches a headed browser, injects a recorder script that survives navigation, and turns your clicks, keystrokes and navigations into one line of C# each. There is no --headless option here, and --selector-priority is reserved on record: only codegen applies it.
motus record --url http://localhost:5000/ \
--output tests/SignInFlow.cs \
--namespace MyApp.Tests \
--class-name SignInFlowTests \
--method-name CanSignIn
Press Enter when you are done. Consecutive fills on one selector get coalesced into a single line:
await page.GotoAsync("http://localhost:5000/");
await page.Locator("css=nav.topbar > button.link-btn").ClickAsync();
await page.GotoAsync("http://localhost:5000/sign-in");
await page.Locator("data-testid=email").FillAsync("ada@example.com");
Look at the second line. The top bar button has no test id, and role=button[name="Sign in"] also matches the form's submit button, so inference falls all the way through to CSS. That line is now coupled to a class name, which is exactly the kind of thing that breaks later.
Step 3: the manifest
Both commands write a sidecar beside their output, here tests/SignInFlow.selectors.json. Each entry records the selector, the locator method, where it sits in your source, the URL it was captured on, and a fingerprint of the element.
{
"entries": [
{
"selector": "css=nav.topbar > button.link-btn",
"locatorMethod": "Locator",
"sourceFile": "/work/app/tests/SignInFlow.cs",
"sourceLine": 15,
"pageUrl": "http://localhost:5000/",
"fingerprint": {
"tagName": "button",
"keyAttributes": {},
"visibleText": "Sign in",
"ancestorPath": "html > body > nav",
"hash": "8095124e9ec5e2dad1577e7fff57f70add38c2d933b2861ce27b4c01015bbd17"
}
}
]
}
keyAttributes holds only id, name, role, data-testid, aria-label, type and href, which is why it is empty for that button. ancestorPath is three levels of tag names, and hash is a SHA-256 over the four fields above it. Notice what is not in there: class names. Leaving them out is what lets a fingerprint outlive a restyling.
Step 4: check the selectors
motus check-selectors parses your C# with Roslyn, picks out the calls to Locator, GetByRole, GetByText, GetByTestId, GetByLabel, GetByPlaceholder, GetByAltText and GetByTitle, and resolves each one against a live page. The match count is the verdict.
| Verdict | Meaning | Under --ci |
|---|---|---|
HEALTHY |
Exactly one match | Passes |
BROKEN |
No match, or the call would not dispatch | Exits non-zero |
AMBIGUOUS |
More than one match | Passes, but the test is a coin toss |
SKIPPED |
Interpolated, or no manifest entry to supply a URL | Passes |
--base-url points every selector at one page. With --manifest, each one is checked against the URL it was recorded on, which is the only way a multi-page flow checks correctly. Say the design system renames .topbar to .appbar and you run this:
motus check-selectors "tests/**/*.cs" --manifest tests/SignInFlow.selectors.json
STATUS SELECTOR FILE:LINE MATCHES
---------- ---------------------------------------- ----------------------------------- -------
BROKEN css=nav.topbar > button.link-btn SignInFlow.cs:15 0
-> Suggestion (High, css): Locator("css=nav.appbar > button.link-btn")
-> Suggestion (High, xpath): Locator("xpath=/html/body/nav[1]/button[1]")
HEALTHY data-testid=email SignInFlow.cs:17 1
HEALTHY data-testid=password SignInFlow.cs:18 1
HEALTHY data-testid=sign-in SignInFlow.cs:19 1
Total 4 | 3 healthy | 1 broken | 0 ambiguous | 0 skipped
Step 5: repair
Now the part that saves you the morning. For a broken selector that has a manifest entry, Motus scans the live page for the fingerprinted element and grades what it finds. An exact hash recompute is High. All key attributes matching, or at least three of them, is Medium. Same tag and ancestor path with fewer than three is Low. It then regenerates selectors through every strategy and keeps the ones that resolve uniquely.
--fix applies the first suggestion, and only when that suggestion grades High. The edit goes through a Roslyn rewriter that replaces the call and leaves the rest of the file exactly as it was, comments and formatting included. The original is copied to <file>.bak unless you pass --no-backup.
motus check-selectors "tests/**/*.cs" \
--manifest tests/SignInFlow.selectors.json --fix
A repaired row prints as FIXED with a -> Fixed: line under it. A renamed test id or an edited label grades Medium or Low, so it gets reported and left alone for a person to look at. --interactive walks you through those one at a time in the visual runner, with the candidate highlighted on the page. Repairs need a fingerprint, so both flags require --manifest, and you cannot combine them. A usage error exits 2.
In CI
Run the check as its own job against a deployed environment:
motus check-selectors "tests/**/*.cs" \
--base-url https://staging.example.com \
--ci --json selector-report.json
--ci exits non-zero as soon as anything is broken, and --json writes the full result out for a build annotation. Keep --fix out of CI. A repair is a source change, and source changes belong in a pull request where somebody reads them.
One thing that will catch you out
--detect-listeners adds a second codegen pass over CDP DOMDebugger.getEventListeners to find clickable elements carrying a directly attached handler. It sees vanilla JavaScript and jQuery listeners. It does not see React's delegated events, which are bound at the root rather than on the element. On a React application you get back only the semantic set, so plan on adding test ids to whatever the crawl cannot name.
The full option lists live in the recording and code generation guide and the CLI reference, and Motus 1.0 covers what sits underneath all of it. If you have a suite already, point check-selectors at it and tell me what comes back. I am most curious about the ambiguous ones.