Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions BACKDOOR_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Backdoor Support in Plugin.Maui.UITestHelpers

This document explains how to use the backdoor functionality in Plugin.Maui.UITestHelpers.Appium, which provides support for invoking app methods directly from UI tests, similar to Xamarin.UITest's backdoor feature.

## Overview

Backdoors allow UI tests to invoke methods in the app under test without going through the UI. This is useful for:
- Setting up test data
- Changing app state for specific test scenarios
- Retrieving internal app state for assertions
- Bypassing complex UI flows for test setup

## Basic Usage

### Simple Method Invocation

```csharp
// Invoke a method without return value
app.Invoke("SetupTestData");

// Invoke a method with parameters
app.Invoke("SetUserPreference", "theme", "dark");
```

### Typed Method Invocation

```csharp
// Invoke a method that returns a string
string result = app.Invoke<string>("GetCurrentUser");

// Invoke a method that returns an integer
int count = app.Invoke<int>("GetItemCount");

// Invoke a method that returns a complex object
var settings = app.Invoke<AppSettings>("GetSettings");
```

## App-Side Implementation

To support backdoor calls in your .NET MAUI app, you need to implement a handler that responds to the `mobile: backdoor` script execution. This can be done using custom handlers or dependency injection.

### Example Implementation

Here's a basic example of how to implement backdoor support in your MAUI app:

```csharp
public class BackdoorService
{
public string GetCurrentUser() => "[email protected]";

public void SetupTestData()
{
// Initialize test data
}

public int GetItemCount() => 42;
}

// Register in MauiProgram.cs
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});

#if DEBUG
// Register backdoor service for testing
builder.Services.AddSingleton<BackdoorService>();
#endif

return builder.Build();
}
}
```

## Platform-Specific Considerations

The backdoor implementation uses Appium's `ExecuteScript` functionality with the `mobile: backdoor` command. Different platforms may handle this differently:

- **Android**: May require custom implementation in the Android driver
- **iOS**: May require custom WDA (WebDriverAgent) implementation
- **Windows**: May require custom implementation in WinAppDriver

## Error Handling

If a backdoor method fails or doesn't exist, the `Invoke` methods will:
- Return `null` for untyped invocations
- Return `default(T)` for typed invocations
- Not throw exceptions (fail gracefully)

## Best Practices

1. **Use sparingly**: Backdoors should complement, not replace, UI testing
2. **Guard with preprocessor directives**: Only include backdoor handlers in test builds
3. **Document your backdoor methods**: Make it clear what each method does
4. **Keep methods simple**: Backdoor methods should be straightforward and fast
5. **Avoid UI operations**: Don't manipulate UI directly from backdoor methods

## Testing the Implementation

You can test that the backdoor functionality is working with a simple test:

```csharp
[Test]
public void BackdoorBasicTest()
{
// Test that backdoor calls don't throw exceptions
Assert.DoesNotThrow(() => app.Invoke("NonExistentMethod"));

// Test with parameters
Assert.DoesNotThrow(() => app.Invoke("TestMethod", "arg1", 123));

// Test typed return (will be null/default if not implemented)
var result = app.Invoke<string>("GetTestValue");
Assert.That(result, Is.Null.Or.InstanceOf<string>());
}
```

## Troubleshooting

If backdoor calls aren't working:

1. **Check app implementation**: Ensure your app properly handles `mobile: backdoor` script execution
2. **Verify method names**: Method names are case-sensitive
3. **Check platform support**: Some platforms may require additional setup
4. **Review logs**: Enable verbose logging to see what's happening with script execution

For more information, see the [Xamarin.UITest backdoor documentation](https://learn.microsoft.com/en-us/appcenter/test-cloud/frameworks/uitest/features/backdoors) for comparison and additional context.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ Install with the dotnet CLI, for example: `dotnet add package Plugin.Maui.UITest

All platforms that are supported by the cross section of the support of Appium and .NET MAUI.

## Features

### Backdoor Support

This library now includes backdoor support similar to Xamarin.UITest, allowing you to invoke app methods directly from your UI tests without going through the UI. This is useful for setting up test data, changing app state, or retrieving internal information.

```csharp
// Basic usage
app.Invoke("SetupTestData");

// With parameters
app.Invoke("SetUserPreference", "theme", "dark");

// Typed return values
string currentUser = app.Invoke<string>("GetCurrentUser");
int itemCount = app.Invoke<int>("GetItemCount");
```

For detailed usage instructions and app-side implementation, see [BACKDOOR_USAGE.md](BACKDOOR_USAGE.md).

<!--## API Usage

TBD -->
Expand Down
18 changes: 18 additions & 0 deletions samples/UITests.Shared/AppiumActionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,22 @@ public void GetSystemBarsTest()
Assert.That(systemBars, Is.Not.Null);
Assert.That(systemBars, Is.Not.Empty);
}

[Test]
public void BackdoorInvokeTest()
{
// Test basic backdoor functionality - this should not throw even if the method doesn't exist
// The app would need to implement the backdoor handler to respond meaningfully

// Act & Assert - should not throw an exception
Assert.DoesNotThrow(() => App.Invoke("TestMethod"));
Assert.DoesNotThrow(() => App.Invoke("TestMethodWithArgs", "arg1", 123));

// Test typed invoke - should return default if method doesn't exist or handler not implemented
var result = App.Invoke<string>("GetTestString");

// Since there's no backdoor handler in the sample app, this should return null/default
// In a real scenario with a properly configured app, this would return actual values
Assert.That(result, Is.Null.Or.InstanceOf<string>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using OpenQA.Selenium.Appium;
using Plugin.Maui.UITestHelpers.Core;

namespace Plugin.Maui.UITestHelpers.Appium
{
/// <summary>
/// Provides backdoor method invocation capabilities for Appium-based UI tests.
/// Backdoors allow tests to invoke app methods directly without going through the UI,
/// similar to Xamarin.UITest's backdoor functionality.
/// </summary>
public class AppiumBackdoorActions : ICommandExecutionGroup
{
const string InvokeCommand = "invoke";

readonly AppiumApp _appiumApp;

readonly List<string> _commands = new()
{
InvokeCommand
};

public AppiumBackdoorActions(AppiumApp appiumApp)
{
_appiumApp = appiumApp ?? throw new ArgumentNullException(nameof(appiumApp));
}

public bool IsCommandSupported(string commandName)
{
return _commands.Contains(commandName, StringComparer.OrdinalIgnoreCase);
}

public CommandResponse Execute(string commandName, IDictionary<string, object> parameters)
{
return commandName switch
{
InvokeCommand => Invoke(parameters),
_ => CommandResponse.FailedEmptyResponse,
};
}

CommandResponse Invoke(IDictionary<string, object> parameters)
{
try
{
var methodName = parameters.TryGetValue("methodName", out var methodNameObj) ? methodNameObj as string : null;
var args = parameters.TryGetValue("args", out var argsObj) ? argsObj as object[] ?? Array.Empty<object>() : Array.Empty<object>();

if (string.IsNullOrEmpty(methodName))
{
return CommandResponse.FailedEmptyResponse;
}

// Create the script parameters for the backdoor invocation
var scriptParams = new Dictionary<string, object>
{
["command"] = "backdoor",
["methodName"] = methodName,
["args"] = args
};

// Execute the script to invoke the backdoor method
// This uses Appium's executeScript capability which can be customized
// by the app under test to handle backdoor method invocations
var result = _appiumApp.Driver.ExecuteScript("mobile: backdoor", scriptParams);

return new CommandResponse(result, CommandResponseResult.Success);
}
catch (Exception ex)
{
// Log the exception for debugging purposes
System.Diagnostics.Debug.WriteLine($"Backdoor invocation failed: {ex.Message}");
return CommandResponse.FailedEmptyResponse;
}
}
}
}
43 changes: 42 additions & 1 deletion src/Plugin.Maui.UITestHelpers.Appium/AppiumApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace Plugin.Maui.UITestHelpers.Appium
{
public abstract class AppiumApp : IApp, IScreenshotSupportedApp, ILogsSupportedApp
public abstract class AppiumApp : IApp, IScreenshotSupportedApp, ILogsSupportedApp, IBackdoorSupportedApp
{
protected readonly AppiumDriver _driver;
protected readonly IConfig _config;
Expand All @@ -30,6 +30,7 @@ public AppiumApp(AppiumDriver driver, IConfig config)
_commandExecutor.AddCommandGroup(new AppiumScrollActions(this));
_commandExecutor.AddCommandGroup(new AppiumOrientationActions(this));
_commandExecutor.AddCommandGroup(new AppiumLifecycleActions(this));
_commandExecutor.AddCommandGroup(new AppiumBackdoorActions(this));
}

public abstract ApplicationState AppState { get; }
Expand Down Expand Up @@ -67,6 +68,46 @@ public IEnumerable<string> GetLogEntries(string logType)
}
}

public object? Invoke(string methodName, params object[] args)
{
var response = CommandExecutor.Execute("invoke", new Dictionary<string, object>
{
{ "methodName", methodName },
{ "args", args }
});

return response.Result == CommandResponseResult.Success ? response.Value : null;
}

public T? Invoke<T>(string methodName, params object[] args)
{
var result = Invoke(methodName, args);

if (result == null)
return default(T);

try
{
// Handle simple type conversions
if (result is T directResult)
return directResult;

// Handle JSON string results that need deserialization
if (result is string jsonString && typeof(T) != typeof(string))
{
return System.Text.Json.JsonSerializer.Deserialize<T>(jsonString);
}

// Attempt conversion for value types
return (T)Convert.ChangeType(result, typeof(T));
}
catch
{
// If conversion fails, return default
return default(T);
}
}

#nullable disable
public virtual IUIElement FindElement(string id)
{
Expand Down
50 changes: 50 additions & 0 deletions src/Plugin.Maui.UITestHelpers.Core/IApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ public interface ILogsSupportedApp : IApp
IEnumerable<string> GetLogEntries(string logType);
}

/// <summary>
/// Interface for apps that support backdoor method invocation.
/// Backdoors allow tests to invoke app methods directly without going through the UI.
/// </summary>
public interface IBackdoorSupportedApp : IApp
{
/// <summary>
/// Invokes a backdoor method in the app with the specified method name and arguments.
/// </summary>
/// <param name="methodName">The name of the method to invoke</param>
/// <param name="args">Arguments to pass to the method</param>
/// <returns>The result of the method invocation, or null if no result</returns>
object? Invoke(string methodName, params object[] args);

/// <summary>
/// Invokes a backdoor method in the app with the specified method name and arguments,
/// returning a strongly typed result.
/// </summary>
/// <typeparam name="T">The expected return type</typeparam>
/// <param name="methodName">The name of the method to invoke</param>
/// <param name="args">Arguments to pass to the method</param>
/// <returns>The result of the method invocation, or default(T) if no result</returns>
T? Invoke<T>(string methodName, params object[] args);
}

public static class AppExtensions
{
public static void Click(this IApp app, float x, float y)
Expand Down Expand Up @@ -68,4 +93,29 @@ public static IEnumerable<string> GetLogTypes(this IApp app) =>
public static IEnumerable<string> GetLogEntries(this IApp app, string logType) =>
app.As<ILogsSupportedApp>().GetLogEntries(logType);
}

public static class BackdoorSupportedAppExtensions
{
/// <summary>
/// Invokes a backdoor method in the app with the specified method name and arguments.
/// </summary>
/// <param name="app">The app instance</param>
/// <param name="methodName">The name of the method to invoke</param>
/// <param name="args">Arguments to pass to the method</param>
/// <returns>The result of the method invocation, or null if no result</returns>
public static object? Invoke(this IApp app, string methodName, params object[] args) =>
app.As<IBackdoorSupportedApp>().Invoke(methodName, args);

/// <summary>
/// Invokes a backdoor method in the app with the specified method name and arguments,
/// returning a strongly typed result.
/// </summary>
/// <typeparam name="T">The expected return type</typeparam>
/// <param name="app">The app instance</param>
/// <param name="methodName">The name of the method to invoke</param>
/// <param name="args">Arguments to pass to the method</param>
/// <returns>The result of the method invocation, or default(T) if no result</returns>
public static T? Invoke<T>(this IApp app, string methodName, params object[] args) =>
app.As<IBackdoorSupportedApp>().Invoke<T>(methodName, args);
}
}
Loading