diff --git a/BACKDOOR_USAGE.md b/BACKDOOR_USAGE.md new file mode 100644 index 0000000..f2b4727 --- /dev/null +++ b/BACKDOOR_USAGE.md @@ -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("GetCurrentUser"); + +// Invoke a method that returns an integer +int count = app.Invoke("GetItemCount"); + +// Invoke a method that returns a complex object +var settings = app.Invoke("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() => "testuser@example.com"; + + 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() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + +#if DEBUG + // Register backdoor service for testing + builder.Services.AddSingleton(); +#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("GetTestValue"); + Assert.That(result, Is.Null.Or.InstanceOf()); +} +``` + +## 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. \ No newline at end of file diff --git a/README.md b/README.md index 5800e19..86251ec 100644 --- a/README.md +++ b/README.md @@ -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("GetCurrentUser"); +int itemCount = app.Invoke("GetItemCount"); +``` + +For detailed usage instructions and app-side implementation, see [BACKDOOR_USAGE.md](BACKDOOR_USAGE.md). + diff --git a/samples/UITests.Shared/AppiumActionsTests.cs b/samples/UITests.Shared/AppiumActionsTests.cs index d4f09b7..890a7ad 100644 --- a/samples/UITests.Shared/AppiumActionsTests.cs +++ b/samples/UITests.Shared/AppiumActionsTests.cs @@ -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("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()); + } } \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumBackdoorActions.cs b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumBackdoorActions.cs new file mode 100644 index 0000000..aac2b77 --- /dev/null +++ b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumBackdoorActions.cs @@ -0,0 +1,76 @@ +using OpenQA.Selenium.Appium; +using Plugin.Maui.UITestHelpers.Core; + +namespace Plugin.Maui.UITestHelpers.Appium +{ + /// + /// 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. + /// + public class AppiumBackdoorActions : ICommandExecutionGroup + { + const string InvokeCommand = "invoke"; + + readonly AppiumApp _appiumApp; + + readonly List _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 parameters) + { + return commandName switch + { + InvokeCommand => Invoke(parameters), + _ => CommandResponse.FailedEmptyResponse, + }; + } + + CommandResponse Invoke(IDictionary 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() : Array.Empty(); + + if (string.IsNullOrEmpty(methodName)) + { + return CommandResponse.FailedEmptyResponse; + } + + // Create the script parameters for the backdoor invocation + var scriptParams = new Dictionary + { + ["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; + } + } + } +} \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/AppiumApp.cs b/src/Plugin.Maui.UITestHelpers.Appium/AppiumApp.cs index dfb6b2e..ea88439 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/AppiumApp.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/AppiumApp.cs @@ -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; @@ -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; } @@ -67,6 +68,46 @@ public IEnumerable GetLogEntries(string logType) } } + public object? Invoke(string methodName, params object[] args) + { + var response = CommandExecutor.Execute("invoke", new Dictionary + { + { "methodName", methodName }, + { "args", args } + }); + + return response.Result == CommandResponseResult.Success ? response.Value : null; + } + + public T? Invoke(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(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) { diff --git a/src/Plugin.Maui.UITestHelpers.Core/IApp.cs b/src/Plugin.Maui.UITestHelpers.Core/IApp.cs index 636ae85..46a3b27 100644 --- a/src/Plugin.Maui.UITestHelpers.Core/IApp.cs +++ b/src/Plugin.Maui.UITestHelpers.Core/IApp.cs @@ -29,6 +29,31 @@ public interface ILogsSupportedApp : IApp IEnumerable GetLogEntries(string logType); } + /// + /// Interface for apps that support backdoor method invocation. + /// Backdoors allow tests to invoke app methods directly without going through the UI. + /// + public interface IBackdoorSupportedApp : IApp + { + /// + /// Invokes a backdoor method in the app with the specified method name and arguments. + /// + /// The name of the method to invoke + /// Arguments to pass to the method + /// The result of the method invocation, or null if no result + object? Invoke(string methodName, params object[] args); + + /// + /// Invokes a backdoor method in the app with the specified method name and arguments, + /// returning a strongly typed result. + /// + /// The expected return type + /// The name of the method to invoke + /// Arguments to pass to the method + /// The result of the method invocation, or default(T) if no result + T? Invoke(string methodName, params object[] args); + } + public static class AppExtensions { public static void Click(this IApp app, float x, float y) @@ -68,4 +93,29 @@ public static IEnumerable GetLogTypes(this IApp app) => public static IEnumerable GetLogEntries(this IApp app, string logType) => app.As().GetLogEntries(logType); } + + public static class BackdoorSupportedAppExtensions + { + /// + /// Invokes a backdoor method in the app with the specified method name and arguments. + /// + /// The app instance + /// The name of the method to invoke + /// Arguments to pass to the method + /// The result of the method invocation, or null if no result + public static object? Invoke(this IApp app, string methodName, params object[] args) => + app.As().Invoke(methodName, args); + + /// + /// Invokes a backdoor method in the app with the specified method name and arguments, + /// returning a strongly typed result. + /// + /// The expected return type + /// The app instance + /// The name of the method to invoke + /// Arguments to pass to the method + /// The result of the method invocation, or default(T) if no result + public static T? Invoke(this IApp app, string methodName, params object[] args) => + app.As().Invoke(methodName, args); + } }