Every framework has an extension model. The list of interfaces doesn't tell you whether it's any good. What tells you is whether the framework's own features are allowed to skip it.
We wrote the rule into the Motus README before the first release: every built-in selector strategy, lifecycle hook, wait condition and reporter goes through the same IPluginContext you get, and there are no internal shortcuts. The extension documentation shipped this week with 1.0.7, so the contract is something you can read now instead of something you take on trust. Here is what that rule costs us, and why I think it is worth paying.
What the door looks like
public interface IPluginContext
{
void RegisterSelectorStrategy(ISelectorStrategy strategy);
void RegisterWaitCondition(IWaitCondition condition);
void RegisterLifecycleHook(ILifecycleHook hook);
void RegisterReporter(IReporter reporter);
void RegisterAccessibilityRule(IAccessibilityRule rule);
IMotusLogger CreateLogger(string categoryName);
}
Six members, and five of them register something. That is the whole door. A plugin is a class that implements IPlugin and gets handed one of these:
public interface IPlugin
{
string PluginId { get; }
string Name { get; }
string Version { get; }
string? Author { get; }
string? Description { get; }
Task OnLoadedAsync(IPluginContext context);
Task OnUnloadedAsync();
}
What Motus puts through its own door
We build the built-ins as an ordinary array of IPlugin, and each one gets OnLoadedAsync called with the same IPluginContext instance a third party receives:
var builtins = new IPlugin[]
{
new BuiltinSelectorsPlugin(),
new AccessibilityRulesPlugin(),
new AccessibilityAuditHook(options.Accessibility),
};
BuiltinSelectorsPlugin makes five RegisterSelectorStrategy calls, one per built-in prefix. AccessibilityRulesPlugin makes nine RegisterAccessibilityRule calls, one per WCAG rule. AccessibilityAuditHook registers itself with RegisterLifecycleHook, exactly as a third-party hook would.
The visual runner is the one that convinced me, because it is a whole application and not just a rule or two. When motus run --visual sees a page, it attaches its timeline recorder like this:
page.Context.GetPluginContext().RegisterLifecycleHook(hook);
GetPluginContext() is a public method on IBrowserContext, so the runner has no more access to the engine than your own code does. The timeline you watch during a visual run is built from ILifecycleHook callbacks and nothing else. Anything that timeline can show you, a hook you write can see too.
Here are the five registrations, and what we put through each one:
| Registration | What it takes | What Motus registers |
|---|---|---|
RegisterSelectorStrategy |
ISelectorStrategy: a prefix, a priority, resolution, and selector generation |
five strategies: css, xpath, text, role, data-testid |
RegisterWaitCondition |
IWaitCondition: a name and an EvaluateAsync(IPage, ...) |
nothing built-in registers one today |
RegisterLifecycleHook |
ILifecycleHook: eight callbacks around navigation, actions, page create and close, console output, and page errors |
the accessibility audit hook, and the visual runner's timeline recorder |
RegisterReporter |
IReporter: run start, test start, test end, run end |
the console, HTML, JUnit and TRX reporters implement this interface |
RegisterAccessibilityRule |
IAccessibilityRule: a rule id, a description, and Evaluate(node, context) |
the nine built-in WCAG rules |
One interface sits beside these rather than under them. IAccessibilityReporter is opt-in: you register through RegisterReporter, and the engine checks at run time whether your reporter also implements it. All four built-in reporters do.
How a plugin gets found
[MotusPlugin] is a marker attribute with no constructor parameters and no properties. The work happens at compile time, where a generator finds the marked types, including ones in assemblies you reference, and writes this for you:
// <auto-generated/>
internal static class MotusPluginRegistry
{
[ModuleInitializer]
internal static void Register()
{
PluginDiscovery.Factory = static () =>
new IPlugin[]
{
new global::Acme.AnalyticsSelectorsPlugin(),
};
}
}
Nothing scans an assembly at run time. If your marked class can't be constructed that way, the build tells you: MOTUS001 for an abstract class, MOTUS002 for a class that doesn't implement IPlugin, MOTUS003 for a missing public parameterless constructor, MOTUS004 for a generic class. Each one names the type and says it will be skipped.
A strategy that finds elements by a data-analytics-id attribute is about as small as a plugin gets:
using Motus.Abstractions;
public sealed class AnalyticsIdSelectorStrategy : ISelectorStrategy
{
public string StrategyName => "analytics-id";
public int Priority => 45;
public Task<IReadOnlyList<IElementHandle>> ResolveAsync(
string selector, IFrame frame, bool pierceShadow = true, CancellationToken ct = default) =>
frame.Locator($"[data-analytics-id='{selector}']").ElementHandlesAsync();
public async Task<string?> GenerateSelector(IElementHandle element, CancellationToken ct = default)
{
var value = await element.GetAttributeAsync("data-analytics-id", ct);
return value is null ? null : $"analytics-id={value}";
}
}
[MotusPlugin]
public sealed class AnalyticsSelectorsPlugin : IPlugin
{
public string PluginId => "acme.analytics-selectors";
public string Name => "Analytics id selectors";
public string Version => "1.0.0";
public string? Author => "Acme";
public string? Description => "Resolves elements by data-analytics-id.";
public Task OnLoadedAsync(IPluginContext context)
{
context.RegisterSelectorStrategy(new AnalyticsIdSelectorStrategy());
return Task.CompletedTask;
}
public Task OnUnloadedAsync() => Task.CompletedTask;
}
After that, page.Locator("analytics-id=checkout-submit") resolves through your code. A selector routes to a strategy by exact prefix name, so registering analytics-id is what makes the prefix exist. Priority is a different question: it decides which strategy the recorder asks first when it has an element and needs a selector for it.
Where this is not finished
Two things are worth saying out loud, because a post that only lists the parts that work is an ad.
CreateLogger returns a no-op today. It hands back a shared null logger and throws away the category name, so every diagnostic a plugin writes goes nowhere. The interface is there and the pipeline behind it isn't.
There is also no RegisterAssertion. LocatorAssertions, PageAssertions and ResponseAssertions are sealed with internal constructors, and the retry engine behind them is internal. You can write extension methods on those sealed classes, but you can't reuse the polling and the failure-message shape that the built-in assertions use. By my own standard, the assertion surface is incomplete. It is on the list.
The loading path is not perfectly even either. Built-in plugins load first and their failures propagate, because a browser context without selector strategies is not much use to anyone. A plugin of yours that throws from OnLoadedAsync is skipped and the run continues. Built-in plugin ids are reserved before any user plugin is considered, so a plugin cannot suppress a built-in by colliding on id, and manual plugins passed through LaunchOptions.Plugins do win over auto-discovered ones.
Why I keep the rule
Building a feature on your own public interfaces is slower than reaching into the engine. You have to design the interface before the feature, and then keep designing it when the feature turns out to need one more callback. The test is easy to say and hard to pass: could somebody outside the company have built this with the packages we publish? What you get back is that by the time an interface goes public, something real has already leaned on it. The nine accessibility rules were the first real use of IAccessibilityRule, and they are the reason AccessibilityAuditContext exists, because page-wide checks such as duplicate ids cannot be answered from a single node.
Playwright's extensibility is limited to JavaScript-side selector engines and browser context options. Motus hooks are plain .NET. That is the practical difference, and the reason I can say it is that we left ourselves no choice: our own features had to go through the door first.
It is the same argument we made about Verso's extension model, and the same rule that shaped the transport. If you find a place in the source where a built-in has access you don't, tell me. That is a bug in the interface, not a feature of the engine.