diff --git a/Automation/MSTestX.Console/Adb/AdbClient.cs b/Automation/MSTestX.Console/Adb/AdbClient.cs index ae2cefa..c87c6fb 100644 --- a/Automation/MSTestX.Console/Adb/AdbClient.cs +++ b/Automation/MSTestX.Console/Adb/AdbClient.cs @@ -173,5 +173,5 @@ private static async Task WriteCommandToSocket(Socket s, string command) } System.Diagnostics.Debug.WriteLine($"OKAY"); } - } + } } diff --git a/Automation/MSTestX.Console/Adb/AdbDevice.UIAutomation.cs b/Automation/MSTestX.Console/Adb/AdbDevice.UIAutomation.cs new file mode 100644 index 0000000..6e574cc --- /dev/null +++ b/Automation/MSTestX.Console/Adb/AdbDevice.UIAutomation.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MSTestX.Console.Adb +{ + internal partial class Device + { + // See https://commandmasters.com/commands/input-android/ for reference to ADB input commands. + + public async Task PerformUIAutomationAction(UIAutomationMessage message) + { + if (message is TapMessage tap) + { + // send a tap command to the device over ADB. + await _client.SendShellCommandAsync($"adb shell input tap {tap.X} {tap.Y}", Serial); + } + if (message is SwipeMessage swipe) + { + // send a swipe command to the device over ADB. + await _client.SendShellCommandAsync($"adb shell input swipe {swipe.FromX} {swipe.FromY} {swipe.ToX} {swipe.ToY} {swipe.DurationMs}", Serial); + } + else if (message is KeyboardMessage keyboard) + { + // TODO: Send modifiers + if(keyboard.Key.Length == 1) + await _client.SendShellCommandAsync($"adb shell input keyevent {KeyToKeyEventCode(keyboard.Key[0])}", Serial); + else + await _client.SendShellCommandAsync($"adb shell input text \"{keyboard.Key.Replace(" ", "%20")}\"", Serial); + } + } + + private static int KeyToKeyEventCode(char key) + { + // This is a very simplified mapping. A real implementation would need to handle more keys and modifiers. + if (char.IsLetterOrDigit(key)) + { + return char.ToUpper(key) - 'A' + 29; // KeyEvent.KEYCODE_A starts at 29 + } + switch (key) + { + case ' ': + return 62; // KeyEvent.KEYCODE_SPACE + case '\n': + return 66; // KeyEvent.KEYCODE_ENTER + case '\t': + return 61; // KeyEvent.KEYCODE_TAB + default: + throw new NotSupportedException($"Key '{key}' is not supported."); + } + } + } +} diff --git a/Automation/MSTestX.Console/Adb/AdbDevice.cs b/Automation/MSTestX.Console/Adb/AdbDevice.cs index 0dfa127..dace43c 100644 --- a/Automation/MSTestX.Console/Adb/AdbDevice.cs +++ b/Automation/MSTestX.Console/Adb/AdbDevice.cs @@ -7,7 +7,7 @@ namespace MSTestX.Console.Adb { - public class Device + internal partial class Device { private AdbClient _client; internal Device(AdbClient client) { _client = client; } diff --git a/Automation/MSTestX.Console/Program.cs b/Automation/MSTestX.Console/Program.cs index cfd3dbb..4770a56 100644 --- a/Automation/MSTestX.Console/Program.cs +++ b/Automation/MSTestX.Console/Program.cs @@ -397,7 +397,7 @@ private static async Task OnApplicationLaunched(System.Net.IPEndPoint? endpoint return; appLaunchDetected = true; int result = 0; - var runner = new TestRunner(endpoint); + var runner = new TestRunner(endpoint, device); try { await Task.Delay(5000); //Give app some time to start up diff --git a/Automation/MSTestX.Console/TestRunner.cs b/Automation/MSTestX.Console/TestRunner.cs index cd955ce..749c143 100644 --- a/Automation/MSTestX.Console/TestRunner.cs +++ b/Automation/MSTestX.Console/TestRunner.cs @@ -14,14 +14,16 @@ namespace MSTestX.Console { - public class TestRunner : IDisposable + internal class TestRunner : IDisposable { private SocketCommunicationManager socket; private System.Net.IPEndPoint _endpoint; + private Adb.Device? _adbdevice; - public TestRunner(System.Net.IPEndPoint endpoint = null) + public TestRunner(System.Net.IPEndPoint endpoint = null, Adb.Device adbdevice = null) { _endpoint = endpoint; + _adbdevice = adbdevice; } public async Task RunTests(string outputFilename, string settingsXml, CancellationToken cancellationToken) @@ -106,8 +108,22 @@ private async Task RunTestsInternal(string outputFilename, string settingsXml, T { continue; } - - if (msg.MessageType == MessageType.TestHostLaunched) + if (msg.MessageType.StartsWith(UIAutomationMessage.AutomationMessageType)) + { + var uia = UIAutomationMessage.FromMessageType(msg.MessageType, msg.Payload); + if (uia is not null) + { + if (_adbdevice is not null) + { + _ = _adbdevice.PerformUIAutomationAction(uia); + } + else + { + System.Console.WriteLine("Cannot send UI Automation message to device."); + } + } + } + else if (msg.MessageType == MessageType.TestHostLaunched) { var thl = JsonDataSerializer.Instance.DeserializePayload(msg); pid = thl.ProcessId; diff --git a/src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs b/src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs index 42d5be8..0bfe3dc 100644 --- a/src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs +++ b/src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs @@ -17,6 +17,8 @@ internal class TestAdapterConnection private int port; private System.Threading.Thread? messageLoopThread; + public bool IsConnected => isConnected; + public TestAdapterConnection(int port) { this.port = port; diff --git a/src/TestAppRunner/TestAppRunner/UIAutomation/AutomationMessages.cs b/src/TestAppRunner/TestAppRunner/UIAutomation/AutomationMessages.cs new file mode 100644 index 0000000..937003d --- /dev/null +++ b/src/TestAppRunner/TestAppRunner/UIAutomation/AutomationMessages.cs @@ -0,0 +1,56 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; +using Newtonsoft.Json; + +namespace MSTestX +{ + internal class UIAutomationMessage + { + internal static string AutomationMessageType => "UIAutomation"; + + public string MessageType => $"{AutomationMessageType}.{GetType().Name}"; + + public static UIAutomationMessage? FromMessageType(string messageType, Newtonsoft.Json.Linq.JToken payload) + { + switch (messageType) + { + case var s when s == $"{AutomationMessageType}.{nameof(TapMessage)}": + return payload.ToObject(); + case var s when s == $"{AutomationMessageType}.{nameof(KeyboardMessage)}": + return payload.ToObject(); + case var s when s == $"{AutomationMessageType}.{nameof(SwipeMessage)}": + return payload.ToObject(); + default: + throw new InvalidOperationException($"Unknown message type: {messageType}"); + } + } + } + + internal class TapMessage : UIAutomationMessage + { + public int X { get; init; } + public int Y { get; init; } + } + + internal class SwipeMessage : UIAutomationMessage + { + public int FromX { get; init; } + public int FromY { get; init; } + public int ToX { get; init; } + public int ToY { get; init; } + public int DurationMs { get; init; } + } + + internal class KeyboardMessage : UIAutomationMessage + { + public string? Key { get; set; } + + public string[]? Modifiers { get; set; } + } +} \ No newline at end of file diff --git a/src/TestAppRunner/TestAppRunner/UIAutomation/UIAutomation.cs b/src/TestAppRunner/TestAppRunner/UIAutomation/UIAutomation.cs new file mode 100644 index 0000000..0c4d95d --- /dev/null +++ b/src/TestAppRunner/TestAppRunner/UIAutomation/UIAutomation.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MSTestX +{ + /// + /// Allows the unit test to reach out to the test runner and send UI automation messages to be performed by the host machine. + /// This is useful for testing scenarios where the test needs to interact with the UI of the host machine, such as clicking buttons or entering text. + /// + public static class UIAutomation + { + /// + /// Tests if the test runner is currently connected to a host runner. If this is false, any attempt to send a message will throw an exception. + /// + public static bool IsConnected => TestAppRunner.ViewModels.TestRunnerVM.Instance.Connection?.IsConnected ?? false; + + /// + /// Sends a tap message to the host machine to simulate a tap at the specified coordinates. + /// Coordinates are in physical pixels relative to the top left of the screen. + /// + /// + /// + public static void Tap(int x, int y) + { + SendMessage(new TapMessage { X = x, Y = y }); + } + + /// + /// Sends a swipe message to the host machine to simulate a swipe from the specified start coordinates to the specified end coordinates over the specified duration. + /// Coordinates are in physical pixels relative to the top left of the screen. + /// + /// + /// + /// + /// + /// + public static void SendSwipe(int fromX, int fromY, int toX, int toY, int durationMs = 500) + { + SendMessage(new SwipeMessage { FromX = fromX, FromY = fromY, ToX = toX, ToY = toY, DurationMs = durationMs }); + } + + /// + /// Simulates a long press gesture at the specified screen coordinates for a given duration. + /// Coordinates are in physical pixels relative to the top left of the screen. + /// + /// The horizontal coordinate, in pixels, where the long press is performed. + /// The vertical coordinate, in pixels, where the long press is performed. + /// The duration of the long press gesture, in milliseconds. The default is 1000 milliseconds. + public static void SendLongPress(int x, int y, int durationMs = 1000) + { + SendMessage(new SwipeMessage { FromX = x, FromY = y, ToX = x, ToY = y, DurationMs = durationMs }); + } + + /// + /// Sends a keyboard input message to the host machine to simulate typing the specified text. + /// + /// + public static void SendTextInput(string text) => SendMessage(new KeyboardMessage { Key = text }); + + /// + /// Sends a keyboard input message to the host machine to simulate typing the specified key. + /// + /// + public static void SendKeyboardInput(char key) => SendMessage(new KeyboardMessage { Key = key.ToString() }); + + private static void SendMessage(UIAutomationMessage message) + { + if (!OperatingSystem.IsAndroid()) + Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Inconclusive("UI Automation is currently only supported on Android."); + if (!IsConnected) + Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Inconclusive("UI Automation not available. The test runner is not connected to MSTestX.Console"); + TestAppRunner.ViewModels.TestRunnerVM.Instance.Connection.SendMessage(message.MessageType, message); + } + } +} diff --git a/src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs b/src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs index 04c641b..07af6fd 100644 --- a/src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs +++ b/src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs @@ -32,6 +32,7 @@ internal class TestRunnerVM : VMBase, ITestExecutionRecorder private System.IO.StreamWriter logOutput; private TrxWriter trxWriter; private TestAdapterConnection connection; + internal TestAdapterConnection Connection => connection; private static TestRunnerVM _Instance;