Skip to main content

Creating Custom Elements

In this guide, we'll go over a custom WebView2Element which will allow us to control an underlying WebView2 control.

We'll look at the WpfWebView2Sample repo.

git clone https://github.com/WPF-Pilot/WpfWebView2Sample.git

The standard Element class has many built in methods, such as Click, Type, and more. If we need more, it is possible to create custom Elements by inheriting from Element<T> on .NET 5+ or Element on older frameworks.

Element<T> requires .NET 5+ because it uses covariant types, which is a C# 9.0 feature. However inheriting from Element works too, but the interface will not be fluent. This means it will not be possible to chain your custom methods with the base methods, eg myCustomElement.Click().MyCustomMethod() will not work, because Click will return the base Element class.

Looking in tests/WpfWebView2Sample.UITests/WebView2Element.cs, we see a barebones implementation,

class WebView2Element : Element<WebView2Element>
{
public WebView2Element(Element element) : base(element)
{
if (element.TypeName != nameof(WebView2))
throw new InvalidOperationException($"Element is not a `{nameof(WebView2)}`.");
}

public WebView2Element WaitForElement(string elementId)
{
while (true)
{
var isElementNull = InvokeAsync<WebView2, string>(x => x.CoreWebView2.ExecuteScriptAsync($@"document.getElementById('{elementId}') == null"));
if (isElementNull == "false")
break;
}

return this;
}

public WebView2Element ClickById(string elementId) =>
InvokeAsync<WebView2>(x => x.CoreWebView2.ExecuteScriptAsync($"document.getElementById('{elementId}').click()"));

public WebView2Element GetElementInnerHtmlById(string elementId, out string html) =>
InvokeAsync<WebView2, string>(x => x.CoreWebView2.ExecuteScriptAsync($"document.getElementById('{elementId}').innerHTML"), out html!);

public WebView2Element NavigateTo(string url) =>
Invoke<WebView2>(x => x.CoreWebView2.Navigate(url));
}

Going over each part, we start by inheriting from Element<WebView2Element>. We need a constructor with the signature public T(Element element), and pass the element to the base class. Recall if we want to retrieve our custom element, we use GetElement<WebView2Element>. GetElement will look for a constructor that matches the above signature, and pass the element to it. If it cannot find one, it will throw an exception. We add additional validation in the constructor to make sure the underlying element is the proper type.

Using Invoke and InvokeAsync, we can construct a cohesive interface that simplifies usage from our tests. This interface and implementation leave a lot to be desired. For example, it is cumbersome to have consumers manage element loading via WaitForElement, and WaitForElement does not have timeout logic, but it still demonstrates the usefulness of custom Elements.

Note the return type of each method is WebView2Element to keep the interface fluent and chainable,

element.NavigateTo("https://example.com")
.WaitForElement("cool-element")
.ClickById("cool-element");

This is not strictly necessary, but is generally a more pleasant interface.

Finally, putting it all together, we have a basic UI test in tests/WpfWebView2Sample.UITests/WpfWebView2SampleTests.cs,

[Test]
public void TestWebView2()
{
using var appDriver = AppDriver.Launch(@"..\..\..\..\..\src\bin\Debug\net7.0-windows\WpfWebView2Sample.exe");

var webView2 = appDriver.GetElement<WebView2Element>(x => x["Name"] == "webView");
webView2.NavigateTo("https://wpfpilot.dev");
webView2.WaitForElement("get-started");

// Change the target="_blank" for testing purposes.
webView2.InvokeAsync<WebView2, string>(x => x.CoreWebView2.ExecuteScriptAsync("document.getElementById('get-started').target = ''"));

webView2.ClickById("get-started");

webView2.WaitForElement("tutorial-id");
webView2.GetElementInnerHtmlById("__docusaurus", out var html);

Assert.True(html.Contains("Tutorial"));
}

If we run the test, it does what we expect and passes. That's all there is to it!