Table of Contents

Custom Modules

This guide explains how to create custom modules to extend WebDriverBiDi.NET with your own commands and functionality.

Advanced guide: This article is for framework authors and library extenders. If you are building a typical automation application, use BiDiDriver and the built-in modules directly instead of creating custom modules.

Overview

WebDriverBiDi.NET's module system is extensible, allowing you to:

  • Implement custom WebDriver BiDi commands
  • Create higher-level abstractions over protocol commands
  • Integrate experimental or browser-specific features
  • Build reusable automation patterns

Module Basics

Module Structure

All modules inherit from the Module base class:

public class CustomModule : Module
{
    public const string CustomModuleName = "custom";

    public CustomModule(IBiDiModuleHost driver)
        : base(driver)
    {
    }

    public override string ModuleName => CustomModuleName;
}

Registering a Module

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

// Register custom module (must be done before calling StartAsync)
MyCustomModule customModule = new MyCustomModule(driver);
driver.RegisterModule(customModule);
await driver.StartAsync(webSocketUrl);

// Access module
var myModule = driver.GetModule<MyCustomModule>("myCustom");

After registration, retrieve a module by name using GetModule<T>:

MyCustomModule myModule = driver.GetModule<MyCustomModule>("myCustom");

This is useful for reaching a module by name when you hold a reference to the driver but not to the module instance you registered. GetModule<T> throws InvalidCastException if the registered module cannot be cast to T, and ArgumentException if no module with that name has been registered.

Note

GetModule<T> is declared on BiDiDriver, not on IBiDiModuleHost. The Module base class stores its host as IBiDiModuleHost (see the note under Module Events), so calling GetModule<T> from inside a module requires a cast to BiDiDriver. Prefer passing any module a custom module depends on into its constructor instead.

Creating Commands

Command Parameters

Define parameters that extend CommandParameters:

public class MyCommandParameters : CommandParameters<MyCommandResult>
{
    public MyCommandParameters(string contextId, string value)
    {
        this.ContextId = contextId;
        this.Value = value;
    }

    [JsonPropertyName("context")]
    public string ContextId { get; }

    [JsonPropertyName("value")]
    public string Value { get; }

    public override string MethodName => "myCustom.myCommand";
}

Override MethodName with the protocol method the parameters are sent as. It, and ResponseType, describe the command rather than carry its parameters, so the transport never writes either inside params; the override needs no [JsonIgnore]. The same holds for a parameters type whose metadata comes from a type-info resolver you register.

Command Results

Define results that extend CommandResult:

public record MyCommandResult : CommandResult
{
    // A received member needs an accessor the serializer can set. A private setter is not one, and
    // [JsonInclude] is what opts a non-public accessor in; without both, the member stays at its default
    // and its value is diverted to AdditionalData.
    [JsonPropertyName("success")]
    [JsonInclude]
    public bool Success { get; internal set; }

    [JsonPropertyName("data")]
    [JsonInclude]
    public string Data { get; internal set; } = string.Empty;
}

A received member needs an accessor the serializer can set. A private set is not one: System.Text.Json sets only public accessors unless [JsonInclude] opts a non-public one in, which is why the members above pair [JsonInclude] with an internal set. Without both, the member silently keeps its default value, and what the remote end sent for it is diverted to AdditionalData; the BIDI037 analyzer reports such a member. The same applies to the members of your event argument types.

Command Method

Implement the command in your module:

public class MyCustomModule : Module
{
    public const string MyCustomModuleName = "myCustom";

    public MyCustomModule(IBiDiModuleHost driver)
        : base(driver)
    {
    }

    public override string ModuleName => MyCustomModuleName;

    public async Task<MyCommandResult> MyCommandAsync(MyCommandParameters parameters)
    {
        return await this.Driver.ExecuteCommandAsync<MyCommandResult>(parameters);
    }
}

Example: Page Utilities Module

Let's create a complete custom module for common page operations:

// Command Parameters
public class WaitForElementCommandParameters : CommandParameters<EvaluateResult>
{
    public override string MethodName => "script.evaluate";  // Use existing protocol command

    public WaitForElementCommandParameters(string contextId, string selector, int timeoutMs)
    {
        this.ContextId = contextId;
        this.Selector = selector;
        this.TimeoutMs = timeoutMs;

        // Build JavaScript that waits for element
        this.Expression = $$"""
            new Promise((resolve) => {
                const checkElement = () => {
                    const element = document.querySelector('{{selector}}');
                    if (element) {
                        resolve({ found: true, tagName: element.tagName });
                    } else {
                        setTimeout(checkElement, 100);
                    }
                };
                checkElement();
                setTimeout(() => resolve({ found: false }), {{timeoutMs}});
            })
            """;

        this.Target = new ContextTarget(contextId);
        this.AwaitPromise = true;
    }

    [JsonPropertyName("expression")]
    public string Expression { get; }

    [JsonPropertyName("target")]
    public ContextTarget Target { get; }

    [JsonPropertyName("awaitPromise")]
    public bool AwaitPromise { get; }

    [JsonIgnore]
    public string ContextId { get; }

    [JsonIgnore]
    public string Selector { get; }

    [JsonIgnore]
    public int TimeoutMs { get; }
}

// Command Result (uses standard EvaluateResult)

// Custom Module
public class PageUtilitiesModule : Module
{
    public const string PageUtilitiesModuleName = "pageUtilities";

    public PageUtilitiesModule(IBiDiModuleHost driver)
        : base(driver)
    {
    }

    public override string ModuleName => PageUtilitiesModuleName;

    /// <summary>
    /// Waits for an element to appear on the page.
    /// </summary>
    /// <param name="contextId">The browsing context ID.</param>
    /// <param name="selector">CSS selector for the element.</param>
    /// <param name="timeout">Maximum time to wait.</param>
    /// <returns>True if element found, false otherwise.</returns>
    public async Task<bool> WaitForElementAsync(
        string contextId,
        string selector,
        TimeSpan timeout)
    {
        WaitForElementCommandParameters parameters =
            new WaitForElementCommandParameters(
                contextId,
                selector,
                (int)timeout.TotalMilliseconds);

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(
            parameters);

        if (result is EvaluateResultSuccess success &&
            success.Result is KeyValuePairCollectionRemoteValue remoteValue &&
            remoteValue.Value is RemoteValueDictionary data)
        {
            return data["found"].As<BooleanRemoteValue>().Value;
        }

        return false;
    }

    public async Task<string?> GetElementTextAsync(string contextId, string selector)
    {
        EvaluateCommandParameters parameters = new EvaluateCommandParameters(
            $"document.querySelector('{selector}')?.textContent",
            new ContextTarget(contextId),
            true);

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(parameters);

        if (result is EvaluateResultSuccess success &&
            success.Result is StringRemoteValue stringValue)
        {
            return stringValue.Value;
        }

        return null;
    }

    public async Task<bool> IsElementVisibleAsync(string contextId, string selector)
    {
        string script = $$"""
            (() => {
                const element = document.querySelector('{{selector}}');
                if (!element) return false;
                const style = window.getComputedStyle(element);
                return style.display !== 'none' && 
                        style.visibility !== 'hidden' && 
                        element.offsetParent !== null;
            })()
            """;

        EvaluateCommandParameters parameters = new EvaluateCommandParameters(
            script,
            new ContextTarget(contextId),
            true);

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(parameters);

        if (result is EvaluateResultSuccess success &&
            success.Result is BooleanRemoteValue boolValue)
        {
            return boolValue.Value;
        }

        return false;
    }

    public async Task<Dictionary<string, object>> GetPageMetricsAsync(string contextId)
    {
        string script = """
            ({
                title: document.title,
                url: window.location.href,
                linkCount: document.querySelectorAll('a').length,
                imageCount: document.querySelectorAll('img').length,
                scriptCount: document.querySelectorAll('script').length,
                styleCount: document.querySelectorAll('link[rel="stylesheet"]').length,
                readyState: document.readyState,
                height: document.documentElement.scrollHeight,
                width: document.documentElement.scrollWidth
            })
            """;

        EvaluateCommandParameters parameters = new EvaluateCommandParameters(
            script,
            new ContextTarget(contextId),
            true);

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(parameters);

        if (result is EvaluateResultSuccess success &&
            success.Result is KeyValuePairCollectionRemoteValue remoteValue &&
            remoteValue.Value is RemoteValueDictionary properties)
        {
            return ToDictionary(properties);
        }

        return new Dictionary<string, object>();
    }

    private static Dictionary<string, object> ToDictionary(RemoteValueDictionary dict)
    {
        Dictionary<string, object> result = new();
        foreach (KeyValuePair<object, RemoteValue> kvp in dict)
        {
            result[kvp.Key.ToString() ?? ""] = kvp.Value.Type switch
            {
                RemoteValueType.String => kvp.Value.As<StringRemoteValue>().Value ?? "",
                RemoteValueType.Number => (object?)kvp.Value.As<NumberRemoteValue>().Value ?? 0,
                RemoteValueType.Boolean => kvp.Value.As<BooleanRemoteValue>().Value,
                RemoteValueType.Null or RemoteValueType.Undefined => (object?)null!,
                _ => (kvp.Value as ValueHoldingRemoteValue)?.ValueObject ?? (object)""
            };
        }
        return result;
    }
}

Using the Custom Module

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

// Register custom module (must be done before calling StartAsync)
PageUtilitiesModule pageUtils = new PageUtilitiesModule(driver);
driver.RegisterModule(pageUtils);
await driver.StartAsync(webSocketUrl);

// Get context
GetTreeCommandResult tree = await driver.BrowsingContext.GetTreeAsync(new());
string contextId = tree.ContextTree[0].BrowsingContextId;

// Navigate
await driver.BrowsingContext.NavigateAsync(
    new NavigateCommandParameters(contextId, "https://example.com")
    { Wait = ReadinessState.Complete });

// Use custom module
bool elementFound = await pageUtils.WaitForElementAsync(
    contextId,
    ".content",
    TimeSpan.FromSeconds(10));

if (elementFound)
{
    string? text = await pageUtils.GetElementTextAsync(contextId, ".content");
    Console.WriteLine($"Content: {text}");

    bool isVisible = await pageUtils.IsElementVisibleAsync(contextId, ".content");
    Console.WriteLine($"Visible: {isVisible}");
}

// Get page metrics
var metrics = await pageUtils.GetPageMetricsAsync(contextId);
Console.WriteLine($"Links: {metrics["linkCount"]}");
Console.WriteLine($"Images: {metrics["imageCount"]}");

Example: Testing Utilities Module

Create a module with common testing helpers:

public class TestUtilitiesModule : Module
{
    public const string TestUtilitiesModuleName = "testUtilities";

    public TestUtilitiesModule(IBiDiModuleHost driver)
        : base(driver)
    {
    }

    public override string ModuleName => TestUtilitiesModuleName;

    public async Task<byte[]> TakeFullPageScreenshotAsync(string contextId)
    {
        // Get page dimensions
        string script = """
            ({
                width: Math.max(
                    document.documentElement.scrollWidth,
                    document.body.scrollWidth
                ),
                height: Math.max(
                    document.documentElement.scrollHeight,
                    document.body.scrollHeight
                )
            })
            """;

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(
            new EvaluateCommandParameters(script, new ContextTarget(contextId), true));

        if (result is EvaluateResultSuccess success &&
            success.Result is KeyValuePairCollectionRemoteValue remoteValue &&
            remoteValue.Value is RemoteValueDictionary dimensions)
        {
            long width = dimensions["width"].As<NumberRemoteValue>();
            long height = dimensions["height"].As<NumberRemoteValue>();

            // Capture screenshot with full page dimensions
            CaptureScreenshotCommandParameters screenshotParams =
                new CaptureScreenshotCommandParameters(contextId)
                {
                    Clip = new BoxClipRectangle
                    {
                        X = 0,
                        Y = 0,
                        Width = width,
                        Height = height
                    }
                };

            CaptureScreenshotCommandResult screenshot =
                await this.Driver.ExecuteCommandAsync<CaptureScreenshotCommandResult>(screenshotParams);

            return Convert.FromBase64String(screenshot.Data);
        }

        throw new Exception("Failed to get page dimensions");
    }

    public async Task<List<string>> GetAllLinksAsync(string contextId)
    {
        string script = "Array.from(document.querySelectorAll('a')).map(a => a.href)";

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(
            new EvaluateCommandParameters(script, new ContextTarget(contextId), true));

        if (result is EvaluateResultSuccess success &&
            success.Result is CollectionRemoteValue remoteValue &&
            remoteValue.Value is RemoteValueList links)
        {
            return links.Select(l => l.As<StringRemoteValue>().Value).ToList();
        }

        return new List<string>();
    }

    public async Task HighlightElementAsync(string contextId, string selector)
    {
        string script = $$"""
            (() => {
                const element = document.querySelector('{{selector}}');
                if (element) {
                    element.style.border = '3px solid red';
                    element.style.backgroundColor = 'yellow';
                    return true;
                }
                return false;
            })()
            """;

        await this.Driver.ExecuteCommandAsync<EvaluateResult>(
            new EvaluateCommandParameters(script, new ContextTarget(contextId), false));
    }

    public async Task InjectCSSAsync(string contextId, string css)
    {
        string script = $$"""
            (() => {
                const style = document.createElement('style');
                style.textContent = `{{css}}`;
                document.head.appendChild(style);
            })()
            """;

        await this.Driver.ExecuteCommandAsync<EvaluateResult>(
            new EvaluateCommandParameters(script, new ContextTarget(contextId), false));
    }
}

Example: Performance Monitoring Module

The Performance module pattern uses Script.EvaluateAsync to run performance.getEntriesByType('navigation') and performance.getEntriesByType('resource') in the browser. See the Test Utilities Module example for the pattern of wrapping ExecuteCommandAsync with custom methods.

Module Events

You can also expose observable events from your custom module:

public class CustomEventsModule : Module
{
    public const string CustomModuleName = "customModule";
    private const string CustomEventName = "custom.eventOccurred";

    private readonly ObservableEventInvocable<CustomEventArgs> onCustomEvent =
        new ObservableEventInvocable<CustomEventArgs>(CustomEventName);

    public CustomEventsModule(IBiDiModuleHost driver)
        : base(driver)
    {
        // Register event with driver
        this.RegisterObservableEvent(this.onCustomEvent);
    }

    public override string ModuleName => CustomModuleName;

    [ObservableEventName(CustomEventName)]
    public ObservableEvent<CustomEventArgs> OnCustomEvent => this.onCustomEvent;
}

public record CustomEventArgs : WebDriverBiDiEventArgs
{
    [JsonPropertyName("data")]
    [JsonInclude]
    public string Data { get; internal set; } = string.Empty;
}

RegisterObservableEvent(invocable) registers the event with the driver and, each time the remote end sends it, raises the ObservableEventInvocable<T> with the deserialized data as the event args. When the type you deserialize is not the event args type you expose, use the overload RegisterObservableEvent<T, TEventArgs>(invocable, Func<T, TEventArgs> eventArgsConverter): it builds the event args through the AOT-safe ToEventArgs factory overload described below. Both overloads also connect the invocable to the driver's reporting of asynchronous observer failures, described below.

The invocable is also how a module raises an event it produces itself rather than one the remote end sends: InvokeNotifyObserversAsync(args) notifies its observers, and InvokeSetObserverErrorReporter(reporter) sets the callback that receives the failures of asynchronous observers, which the registration overloads set for you. Expose the invocable to callers as its base type, ObservableEvent<T>, so that only your module can raise it.

Why IBiDiModuleHost, not IBiDiDriverConfiguration? The Module base class constructor requires IBiDiModuleHost because event registration goes through that interface. When your module calls this.RegisterObservableEvent<T>(...) in its constructor, the base class calls this.Driver.RegisterEvent<T>(...) internally. RegisterEvent<T> is defined on IBiDiModuleHost; it is not present on IBiDiDriverConfiguration, which only exposes RegisterModule and RegisterTypeInfoResolverAsync. Passing a BiDiDriver instance satisfies both interfaces, so your module constructor always receives a BiDiDriver in practice.

Reporting observer failures from a custom executor. A handler registered with ObservableEventHandlerOptions.RunHandlerAsynchronously has its task detached, so a failure in it cannot be thrown at the code that raised the event. Module routes such a failure to the executor it was constructed with, if that executor implements IEventObserverErrorReporter. BiDiDriver does, which is how the failure reaches EventHandlerExceptionBehavior and OnEventHandlerErrorOccurred. If you write your own IBiDiModuleHost, implement that interface too, or the failures of your modules' asynchronous observers are observed and then discarded — never thrown, never reported:

public Func<EventObserverErrorInfo, Task> EventObserverErrorReporter => this.ReportFaultAsync;

EventObserverErrorInfo names the event, the observer, and the exception, and says whether the handler was asynchronous and whether the failure arrived after the handler returned.

What the invoker receives

RegisterEvent<T> takes a Func<EventInfo<T>, Task>. EventInfo<T> carries three things:

Member Contents
EventData The deserialized T — your event args type
AdditionalData Extension properties the remote end put inside the event's params object
AdditionalEventProperties Extension properties the remote end put on the event envelope, alongside method and params

Both dictionaries are ReceivedDataDictionary and are empty rather than null when the remote end sent nothing extra, so a vendor-prefixed field can be read without a null check. They are read-only; ToWritableCopy() returns a Dictionary<string, object?> copy you can change, or whose entries you can add to a command's AdditionalData to send them on.

EventInfo<T>.ToEventArgs packages all three into the event args you hand to your ObservableEvent<T>, copying both dictionaries onto the result so the extension data survives the hop. The overload taking a Func<T, TEventArgs> factory is the one to use in AOT or trimmed applications; the parameterless overload uses T itself as the event args type and throws WebDriverBiDiException when TEventArgs is anything else, which the BIDI035 analyzer reports.

Mark each ObservableEvent<T> property on your module with [ObservableEventName("your.event")], naming the same string you passed to the ObservableEventInvocable<T> constructor, as the module sample above does. The library's analyzers read that attribute from compiled metadata, which is what lets BIDI005 and BIDI015 recognise your module's events in a consuming project that references your module as a package.

Enum Wire Values

Enums used in command parameters, results, and event args serialize as JSON strings through EnumValueJsonConverter<T> (applied with a JsonConverter attribute on the enum). By default the wire value is the member name lowercased (Enabled becomes "enabled"), and deserialization is strict: an incoming string that matches no member throws a JsonException rather than mapping silently. Three attributes adjust this for a custom module's enums:

  • StringEnumValueAttribute (on a member) sets the exact wire string when lowercasing the name is not enough, such as hyphenated protocol values.
  • StringEnumUnmatchedValueAttribute<T> (on the enum) names the member to which any unmatched incoming string deserializes, opting that enum out of strict deserialization.
  • StringEnumNullSentinelValueAttribute<T> (on the enum) names a member that serializes as JSON null, for protocol members where sending null means "reset to default".
[JsonConverter(typeof(EnumValueJsonConverter<WidgetMode>))]
[StringEnumUnmatchedValue<WidgetMode>(Unknown)]
[StringEnumNullSentinelValue<WidgetMode>(Reset)]
public enum WidgetMode
{
    // Serializes as "enabled": the member name lowercased.
    Enabled,

    // Serializes as "power-save"; lowercasing alone cannot produce the hyphen.
    [StringEnumValue("power-save")]
    PowerSave,

    // Deserialized from any incoming string that matches no other member.
    Unknown,

    // Serializes as JSON null, signaling the remote end to reset the mode.
    Reset,
}

Best Practices

1. Namespace Your Commands

Use a clear module prefix for your custom commands:

public class CustomModulesNamespacedCommandParameters : CommandParameters<MyCommandResult>
{
    public override string MethodName => "myCompany.myModule.myCommand";  // Clear namespace

    [JsonPropertyName("value")]
    public string Value { get; set; } = string.Empty;

    public CustomModulesNamespacedCommandParameters(string value)
    {
        this.Value = value;
    }
}

2. Provide Defaults

Make your modules easy to use with sensible defaults:

public async Task<bool> WaitForElementAsync(
    string contextId,
    string selector,
    TimeSpan? timeout = null)  // Optional timeout
{
    timeout = timeout ?? TimeSpan.FromSeconds(30);  // Default
    // ...
    return true; // Placeholder
}

3. Document Your Module

Add XML documentation comments to your module class and methods. See the Page Utilities Module for the structure.

4. Handle Errors Gracefully

public async Task<string?> GetElementTextAsync(string contextId, string selector)
{
    try
    {
        EvaluateCommandParameters parameters = new EvaluateCommandParameters(
            $"document.querySelector('{selector}')?.textContent",
            new ContextTarget(contextId),
            true);

        EvaluateResult result = await this.Driver.ExecuteCommandAsync<EvaluateResult>(parameters);

        if (result is EvaluateResultSuccess success &&
            success.Result is StringRemoteValue stringValue)
        {
            return stringValue.Value;
        }
        else if (result is EvaluateResultException exception)
        {
            Console.WriteLine($"Script error: {exception.ExceptionDetails.Text}");
        }
    }
    catch (WebDriverBiDiException ex)
    {
        Console.WriteLine($"Command error: {ex.Message}");
    }

    return null;  // Graceful fallback
}

5. Make Modules Testable

Extract an interface from your module (e.g., IPageUtilities with WaitForElementAsync and GetElementTextAsync), implement it in your module class, and create a MockPageUtilities for unit tests.

Packaging Custom Modules

Create a NuGet package for reusable modules:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <PackageId>MyCompany.WebDriverBiDi.Extensions</PackageId>
    <Version>1.0.0</Version>
    <Authors>Your Name</Authors>
    <Description>Custom WebDriver BiDi modules</Description>
  </PropertyGroup>
</Project>

Then add the WebDriverBiDi dependency with the .NET CLI, pinning an exact version so an update stays deliberate (see Pinning an exact version):

dotnet add package WebDriverBiDi

AOT support: If your package will be used in AOT environments, include a source-generated JsonSerializerContext with [JsonSerializable] attributes for your custom types. See AOT Compatibility for details.

Advanced: Implementing Protocol Extensions

For actual protocol extensions (not just helper methods):

public class ExperimentalCommandParameters : CommandParameters<ExperimentalCommandResult>
{
    public ExperimentalCommandParameters(string parameter)
    {
        // Use actual protocol command name
        this.Parameter = parameter;
    }

    public override string MethodName => "experimental.newCommand";

    [JsonPropertyName("parameter")]
    public string Parameter { get; }
}

public record ExperimentalCommandResult : CommandResult
{
    [JsonPropertyName("result")]
    [JsonInclude]
    public string Result { get; internal set; } = string.Empty;
}

// Implement in module
public class ExperimentalModule : Module
{
    public const string ExperimentalModuleName = "experimental";

    public ExperimentalModule(IBiDiModuleHost driver)
        : base(driver)
    {
    }

    public override string ModuleName => ExperimentalModuleName;

    public async Task<ExperimentalCommandResult> NewCommandAsync(
        ExperimentalCommandParameters parameters)
    {
        return await this.Driver.ExecuteCommandAsync<ExperimentalCommandResult>(
            parameters);
    }
}

Custom Transport

For scenarios requiring custom message processing — for example, injecting test doubles, logging raw frames, or applying transformations to incoming messages — you can subclass Transport and override CreateIncomingMessage:

/// <summary>
/// Example transport that sees the raw bytes of every inbound message.
/// </summary>
public class LoggingTransport : Transport
{
    public LoggingTransport(Connection connection)
        : base(connection)
    {
    }

    protected override IncomingMessage CreateIncomingMessage(IMemoryOwner<byte> owner, int length)
    {
        // Inspect or log the raw message bytes here before handing them to the base implementation.
        return base.CreateIncomingMessage(owner, length);
    }
}

Pass your custom transport to BiDiDriver via the constructor overload that accepts a Transport:

WebSocketConnection connection = new();
LoggingTransport transport = new(connection);
await using BiDiDriver driver = new(TimeSpan.FromSeconds(60), transport);
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

Other extension points on Transport

Member Purpose
CreateIncomingMessage protected virtual. See the raw bytes of every inbound message before they are parsed
CreateCommand protected virtual. Build the Command envelope — its id, method and parameters — for an outgoing command; override to stamp every command with an extra property
SendCommandAsync public virtual. Send a command and get back the Command that was queued, without waiting for its response. BiDiDriver.ExecuteCommandAsync is the layer above it that waits and deserializes
CancelCommand public virtual. Stop waiting for a command sent through SendCommandAsync, giving a CommandCancellationReason. Returns false when the command had already completed, in which case that outcome stands. The command is remembered so that a late response is recognized and discarded rather than reported as an unknown message (see Error Handling — Transport Error Behavior Configuration)
RegisterEventMessage<T> public virtual. Teach the transport to deserialize an event name into T. BiDiDriver.RegisterEvent<T> calls it for you, so a module needs it only when the transport is driven directly; the envelope type it builds is AOT-safe, needing only T to be resolvable
AddEventMessageType protected. Register an arbitrary message Type for an event name, for a message shape RegisterEventMessage<T> cannot express. The envelope is then read through the serializer's metadata for that type rather than the library's envelope converter — keeping the path AOT-safe, at the cost of accepting a missing method or a null params, which surfaces later as a protocol error. Prefer RegisterEventMessage<T> when the payload type is known at compile time. To intercept or rewrite registrations, override RegisterEventMessage<T>
SerializeCommand protected virtual. Turn a Command into the UTF-8 bytes put on the wire. Override to log or post-process the exact payload
ProcessMessageAsync protected virtual. Handle one inbound message after it is read from the queue. Override to observe or delay individual messages
ReadIncomingMessagesAsync protected virtual. The loop that drains the incoming message queue. Override only to replace the dispatch strategy wholesale
CaptureUnhandledError protected virtual. The single point every non-command failure passes through, identified by its UnhandledErrorKind, before TransportErrorBehavior is applied. Override to observe failures without changing the behavior
AcquireConnectionLockAsync / ReleaseConnectionLock protected virtual. Take and release the exclusive access that connecting, disconnecting, sending and registering a resolver each hold. Override to instrument contention
PendingCommands protected, read-only. The current session's pending-command collection. Each session has its own, created when the transport connects, so the collection changes on reconnect. The size of the window of recent cancellations within which it remembers canceled commands is set by the public MaxTrackedCanceledCommands setting
TimeProvider protected settable. The clock the transport's ShutdownTimeout waits and its commands' timeouts are measured on. Substitute one to drive those waits with virtual time in a test
UnhandledErrors protected, read-only. The UnhandledErrorCollection the transport records failures in under its TransportErrorBehavior settings; see Pending commands and unhandled errors
LastCommandId / GetNextCommandId protected. The ID of the most recently created command, and the method that issues the next one. An override of CreateCommand that builds its own Command should take its ID from GetNextCommandId, or call the base implementation, so that IDs stay unique. IDs are unique for the lifetime of the transport and are not reset when it reconnects

Working with the Command you were handed

SendCommandAsync returns the Command as soon as it is sent. BiDiDriver.ExecuteCommandAsync is built from the members below, and code that drives a transport directly uses the same ones:

Member Purpose
WaitForCompletionAsync(timeout, cancellationToken) Returns true once the command has completed in any way (with a result, a fault or a cancellation) within the timeout, and false if the timeout elapses first. Throws OperationCanceledException if the token is canceled while the command is still pending. A timed-out command is still pending: cancel it with Transport.CancelCommand if you stop waiting
TryGetResult(out CommandResult? result) Gets the result of a command that completed with one. An error response from the remote end is a result too: an ErrorResult, whose IsError is true
ThrownException The exception a command faulted with inside the library, such as a response that could not be deserialized or a lost connection; null otherwise. It is not how the remote end's errors arrive
IsCanceled Whether the command was canceled, by Transport.CancelCommand or by DisconnectAsync clearing the pending commands. A command still pending when the connection is lost faults instead, with a connection exception in ThrownException
ElapsedMilliseconds The time since the transport sent the command, frozen when it completed
SetResult, SetException, Cancel Complete the command. The transport calls them; a test double can too. Each does nothing once the command has completed, and Cancel returns whether it took effect

ExecuteCommandAsync turns an ErrorResult into a WebDriverBiDiCommandException. To raise the same exception from a custom command, or to complete a command in a test double with an error the remote end might send, build the result with ErrorResult.FromErrorInformation(errorType, errorMessage, stackTrace).

Pending commands and unhandled errors

PendingCommands is a PendingCommandCollection. AddPendingCommandAsync adds a sent command and throws once the collection is closed or when the ID is already present. RemovePendingCommand takes out a command whose response arrived. CancelPendingCommand cancels one and remembers it, so that TryRemoveCanceledCommand can recognize its late response. CloseAsync stops the collection accepting commands. Clear and FailAllPendingCommands both throw InvalidOperationException unless CloseAsync has run first. Clear cancels the commands still pending and remembers each one with CommandCancellationReason.ConnectionClosed, while FailAllPendingCommands faults each with its own exception from the factory you pass. TrackedCanceledCommandCount reports how many canceled commands are remembered; how long they are remembered is set by Transport.MaxTrackedCanceledCommands (reached through BiDiDriver.TransportConfiguration), which defaults to PendingCommandCollection.DefaultMaxTrackedCanceledCommands (1,024), as the class remarks describe.

UnhandledErrors is an UnhandledErrorCollection. It holds the four TransportErrorBehavior settings that the transport's properties of the same names forward to. AddUnhandledError(kind, exception) records an UnhandledError, with its ErrorType and Exception, unless the behavior for that kind is Ignore. HasUnhandledErrors(behavior) and TryGetExceptions(behavior, out exceptions) ask about the errors recorded under one behavior, Exceptions returns a snapshot of all of them, and ClearUnhandledErrors empties the collection. The transport records its own failures through CaptureUnhandledError, which is the place to observe them.

Filtering or rewriting inbound messages

CreateIncomingMessage hands you the raw bytes, which the IncomingMessage exposes as MessageData (with MessageLength) or, decoded as UTF-8, as MessageText. The useful hook, though, is the documentTransformer parameter of the IncomingMessage constructor: a transformer that receives the parsed JsonDocument and returns the document to use instead, or null to discard the message entirely. Parse() runs it and sets MessageKind, which is IncomingMessageKind.Uninitialized until then. A discarded message is marked IncomingMessageKind.Filtered and is dropped silently — it is not reported as an unknown message.


/// <summary>
/// A transport that discards inbound messages carrying a vendor channel property, showing the
/// document transformer that <see cref="IncomingMessage"/> accepts.
/// </summary>
public class FilteringTransport : Transport
{
    /// <inheritdoc/>
    protected override IncomingMessage CreateIncomingMessage(IMemoryOwner<byte> owner, int length)
    {
        return new IncomingMessage(owner, length, document =>
        {
            // Return the document unchanged to pass the message through, a different document to
            // rewrite it, or null to discard it without reporting it as an unknown message.
            return document.RootElement.TryGetProperty("goog:channel", out _) ? null : document;
        });
    }
}

The message keeps ownership of the document it parsed, so don't hold on to the one your transformer receives. It is disposed for you when you return null, return a different document, or throw. A document you return is disposed with the message. An exception your transformer throws propagates out of IncomingMessage.Parse. The transport handles a JsonException the same way as a message that is not valid JSON, and captures any other exception as a ProtocolError unhandled error.

Overriding CreateCommand is the supported way to add a vendor extension property to every command; adding it per call through CommandParameters.AdditionalData works too, but goes through reflection-based serialization and so is flagged by BIDI022 for AOT and trimming.

Next Steps

Summary

Custom modules allow you to:

  • Extend WebDriverBiDi.NET with reusable functionality
  • Create domain-specific abstractions
  • Implement experimental features
  • Build shareable automation libraries

The module system is flexible and powerful, enabling you to build exactly the automation framework you need.