WPF Pilot Deep Dive
This reference document will go over how the WPF Pilot life cycle works from end to end, as well as interesting things of note along the way.
We'll go over, in detail, how a simple test like this runs,
[Test]
public void EndToEndTest()
{
using var appDriver = AppDriver.Launch(@"..\MyApp.exe");
var screen = appDriver.GetElement(x => x["Name"] == "Screen");
screen.Invoke<Screen>(x => x.ClearForms())
.Click()
.Assert(x => x.IsFocused);
}
First, we note none of the methods in the entire WpfPilot library are async or return Tasks. Not even InvokeAsync, or RunCodeAsync, they are called like so,
var result = element.InvokeAsync<MyCustomControl>(x => x.MyAsyncMethod());
appDriver.RunCodeAsync(_ => AppManager.InitializeAsync());
This is a deliberate design decision to keep the mental model simple. Concurrent and parallel models are more prone to bugs from the added complexity.
AppDriver.Launchwill launch the given exe using the standardProcessmodule. TheAppDriverwill then attempt to inject theWpfPilot.dllpayload into the launched process. This uses a technique called DLL injection which allows arbitrary code execution in the given process, as well as access to all of the objects and memory of the process. The steps are a bit more convulated due to inherient complexity.- The
AppDriverwill launchWpfPilot.Injector.exefrom theWpfPilotResourcesfolder, compiled in eitherx86orx64depending on the process's underlying architecture. The injector needs to match the process's architecture to successfully inject thedllpayload. - The initial
dllinjected is a compiled C++dll, eitherinjector.x64.dllorinjector.x86.dll. - The above
dllthen injects theWpfPilot.dlland is unloaded from the process.
- The
Once the
WpfPilot.dllis injected, it loads all its required dependencies into the process. This includes things likeNewtonsoft.JsonandLib.Harmony.WpfPilot.dllthen sets up aNamedPipeServerand a command loop. The test suite can now connect to thisNamedPipeand issue commands.GetElementissues aGetVisualTreeCommandto the app using the aboveNamedPipe. TheGetVisualTreeCommandserializes the entire WPF visual tree and sends it over theNamedPipe. This may sound slow, but is incredibly fast, usually within tens of milliseconds on small apps, or hundreds of milliseconds on larger apps.Once the visual tree is received, the matcher is applied to all elements. If there is a match, it is returned, otherwise subsequent
GetVisualTreeCommandsare issued until aTimeoutExceptionis thrown. All returned elements are tracked byAppDriverand refreshed anytime an action is taken. This means if the text of some element is changed, there is no need to call anotherGetElementto get the updated text.var textBox = GetElement(x => x["Name"] == "MyTextBox");
var textInitial = textBox["Text"]; // Empty string.
textBox.Type("Hello world!");
var textNow = textBox["Text"]; // `Hello world!` string.Now that we have a reference to the
screenelement, we can issue commands on it using our existingNamedPipe.Invokeissues anInvokeCommandon the target element and is one of the most flexible and useful methods in WPF Pilot.Invokeexpects aLambdaExpression, which will typically look like a plain old function call, as seen in the example. There are many flavors ofInvokedepending on if we care about the result, if we need to specify a custom element, or if we need to call anasyncmethod. TheLambdaExpressionis serialized into a JSON representation and sent over theNamedPipe. The app then compiles theLambdaExpressionand invokes it on the element. The result is then serialized and returned over theNamedPipeback to the client. Most standard classes will be serializable byNewtonsoft.Json, but not all are, in which casenullis returned.// If we know the underlying element is a `MyControl` and we want the result.
var userId = element.Invoke<MyControl, int>(x => x.CurrentUserId);
// Alternate version for chaining.
element.Invoke<MyControl, int>(x => x.CurrentUserId, out var userId);
// If we know the underlying element is a `MyControl`,
// and the result is `void` or we do not care about it.
element.Invoke<MyControl>(x => x.RefreshPixels());
// The default is a `UIElement` with no result.
element.Invoke(x => x.Focus());Clickissues aClickCommandon thescreenelement. TheClickCommandis a little complicated. WPF uses the mouse coordinates on some elements, such asButtonandMenuItem, to determine if a click event should be raised. Because mouse coordinates cannot be mocked in WPF for security reasons, WPF Pilot hooks the methods of many built-in WPF controls usingLib.Harmonyand rewrites them to not check the mouse coordinates. WPF Pilot also hooks theMouseDeviceclass to fake the mouse button statuses, as they are also commonly checked in event handlers. By hooking, we are referring to rewriting the IL code at runtime, which is a powerful method to change program behavior. It is typically used by game mods to change behavior of video games without direct access to the source code. TheClickCommandthen issues the standard mouse down and mouse up events on the target element.Once the
ClickCommandis done executing,AppDriverrefreshes all elements, as the click action may have changed the UI in some way.AppDriverwill always refresh the UI elements after an action is taken, as it keeps theElementproperties and app in sync. If you need to force a re-sync you can issue any command and the visual tree will refresh.Finally we chain an
Assertto the end.Assertis a useful utility method that checks the given expression and throws a test framework specific assertion failure if it does not pass. It uses reflection to determine the current test framework context, which is typicallyNUnit,xUnit, or the like. If the assertion does not pass, detailed result information is printed, including what the value was, what it was expected to be, and more. The biggest drawback ofAssertis it requires an expression, not a standard lambda, so not all modern C# features are supported within. The compiler will warn about such cases.
For an even deeper look, the complete source code is available here.