Skip to content

Instantly share code, notes, and snippets.

@jimevans
Created July 10, 2025 15:15
Show Gist options
  • Save jimevans/c41e4ec46783c596adf2c73b6d749f74 to your computer and use it in GitHub Desktop.
Save jimevans/c41e4ec46783c596adf2c73b6d749f74 to your computer and use it in GitHub Desktop.
WebDriver BiDi element click with wait for navigation behavior
using WebDriverBiDi;
using WebDriverBiDi.BrowsingContext;
using WebDriverBiDi.Client.Inputs;
using WebDriverBiDi.Input;
using WebDriverBiDi.Script;
using WebDriverBiDi.Session;
/// <summary>
/// Enumerates the expected behavior for page navigation after clicking on an element.
/// </summary>
public enum ClickNavigationBehavior
{
/// <summary>
/// Expect no navigation, omit waiting for navigation after a click.
/// </summary>
None,
/// <summary>
/// Expect a navigation on click, wait for that navigation to begin after click.
/// </summary>
WaitForNavigationStart,
/// <summary>
/// Expect a navigation on click, wait for the resulting page to fire the domContentLoaded event after click.
/// </summary>
WaitForDomContentLoadedEvent,
/// <summary>
/// Expect a navigation on click, wait for the resulting page to fire the load event after click.
/// </summary>
WaitForLoadEvent,
}
/// <summary>
/// Represents a locator for finding an element on the page. Includes methods for interacting with the located element.
/// </summary>
public class Locator
{
private readonly BiDiDriver driver;
private string browsingContextId;
private Func<Task<IList<RemoteValue>>> locatorFunction;
private Locator(BiDiDriver driver, string browsingContextId, Func<Task<IList<RemoteValue>>> locationFunction)
{
this.driver = driver;
this.browsingContextId = browsingContextId;
this.locatorFunction = locationFunction;
}
/// <summary>
/// Locates an element using CSS selectors.
/// </summary>
/// <param name="driver">The driver instance to use.</param>
/// <param name="browsingContextId">The ID of the browsing context in which to locate the element.</param>
/// <param name="cssSelector">The CSS selector to use to locate the element.</param>
/// <returns>The Locator instance.</returns>
public static Locator UsingCss(BiDiDriver driver, string browsingContextId, string cssSelector)
{
return new Locator(driver, browsingContextId, async () =>
{
LocateNodesCommandParameters parameters = new(browsingContextId, new CssLocator(cssSelector));
LocateNodesCommandResult result = await driver.BrowsingContext.LocateNodesAsync(parameters);
return result.Nodes;
});
}
/// <summary>
/// Locates an element using XPath.
/// </summary>
/// <param name="driver">The driver instance to use.</param>
/// <param name="browsingContextId">The ID of the browsing context in which to locate the element.</param>
/// <param name="xpath">The XPath expression to use to locate the element.</param>
/// <returns>The Locator instance.</returns>
public static Locator UsingXPath(BiDiDriver driver, string browsingContextId, string xpath)
{
return new Locator(driver, browsingContextId, async () =>
{
LocateNodesCommandParameters parameters = new(browsingContextId, new XPathLocator(xpath));
LocateNodesCommandResult result = await driver.BrowsingContext.LocateNodesAsync(parameters);
return result.Nodes;
});
}
/// <summary>
/// Asynchronously clicks the element specified by this Locator.
/// </summary>
/// <param name="clickNavigationBehavior">The expected behavior for navigation after a click.</param>
/// <returns>A Task representing information about the operation.</returns>
/// <exception cref="WebDriverBiDiException">
/// Thrown if this Locator resolves to zero elements or more than one element.
/// </exception>
public async Task Click(ClickNavigationBehavior clickNavigationBehavior = ClickNavigationBehavior.None)
{
ManualResetEventSlim navigationStartEvent = new(false);
ManualResetEventSlim navigationCompleteEvent = new(false);
string subscriptionId = string.Empty;
EventObserver<NavigationEventArgs>? navigationStartObserver = null;
EventObserver<NavigationEventArgs>? navigationCompletedObserver = null;
IList<RemoteValue> nodes = await this.Locate();
if (nodes.Count != 1)
{
string message = nodes.Count == 0 ? "No nodes found for locator" : "Locator not unique, returns multiple nodes";
throw new WebDriverBiDiException(message);
}
if (clickNavigationBehavior != ClickNavigationBehavior.None)
{
// If not waiting on navigation, set the events so that no wait is required.
navigationStartEvent.Set();
navigationCompleteEvent.Set();
}
else
{
// Add observers and events for monitoring navigation.
List<string> events = new() { "browsingContext.navigationStarted" };
navigationStartObserver = this.driver.BrowsingContext.OnNavigationStarted.AddObserver((e) => navigationStartEvent.Set());
if (clickNavigationBehavior == ClickNavigationBehavior.WaitForDomContentLoadedEvent)
{
events.Add("browsingContext.domContentLoaded");
navigationCompletedObserver = this.driver.BrowsingContext.OnDomContentLoaded.AddObserver((e) => navigationCompleteEvent.Set());
}
else if (clickNavigationBehavior == ClickNavigationBehavior.WaitForLoadEvent)
{
events.Add("browsingContext.load");
navigationCompletedObserver = this.driver.BrowsingContext.OnLoad.AddObserver((e) => navigationCompleteEvent.Set());
}
else
{
// Not waiting for navigation to complete.
navigationCompleteEvent.Set();
}
List<string> contextList = new() { this.browsingContextId };
SubscribeCommandParameters subscribeParameters = new SubscribeCommandParameters(events, contextList);
SubscribeCommandResult subscribeResult = await this.driver.Session.SubscribeAsync(subscribeParameters);
subscriptionId = subscribeResult.SubscriptionId;
}
InputBuilder inputBuilder = new();
AddClickOnElementAction(inputBuilder, nodes[0].ToSharedReference());
PerformActionsCommandParameters actionsParams = new(this.browsingContextId);
actionsParams.Actions.AddRange(inputBuilder.Build());
await this.driver.Input.PerformActionsAsync(actionsParams);
if (clickNavigationBehavior != ClickNavigationBehavior.None)
{
// Wait for navigation to start for a half-second, which should
// yield enough time for a navigation to start after a click.
navigationStartEvent.Wait(TimeSpan.FromMilliseconds(500));
// Wait for navigation to complete; this timeout should be made configurable.
navigationCompleteEvent.Wait(TimeSpan.FromSeconds(30));
// Clean up the observers and events.
navigationStartObserver?.Unobserve();
navigationCompletedObserver?.Unobserve();
UnsubscribeByIdsCommandParameters unsubscribeParameters = new();
unsubscribeParameters.SubscriptionIds.Add(subscriptionId);
await this.driver.Session.UnsubscribeAsync(unsubscribeParameters);
}
}
private static void AddClickOnElementAction(InputBuilder builder, SharedReference elementReference)
{
builder.AddAction(builder.DefaultPointerInputSource.CreatePointerMove(0, 0, Origin.Element(new ElementOrigin(elementReference))))
.AddAction(builder.DefaultPointerInputSource.CreatePointerDown(PointerButton.Left))
.AddAction(builder.DefaultPointerInputSource.CreatePointerUp());
}
private async Task<IList<RemoteValue>> Locate()
{
return await this.locatorFunction();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment