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
2 changes: 1 addition & 1 deletion Automation/MSTestX.Console/Adb/AdbClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,5 @@ private static async Task WriteCommandToSocket(Socket s, string command)
}
System.Diagnostics.Debug.WriteLine($"OKAY");
}
}
}
}
55 changes: 55 additions & 0 deletions Automation/MSTestX.Console/Adb/AdbDevice.UIAutomation.cs
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
}
2 changes: 1 addition & 1 deletion Automation/MSTestX.Console/Adb/AdbDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace MSTestX.Console.Adb
{
public class Device
internal partial class Device
{
private AdbClient _client;
internal Device(AdbClient client) { _client = client; }
Expand Down
2 changes: 1 addition & 1 deletion Automation/MSTestX.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions Automation/MSTestX.Console/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<TestHostLaunchedPayload>(msg);
pid = thl.ProcessId;
Expand Down
2 changes: 2 additions & 0 deletions src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions src/TestAppRunner/TestAppRunner/UIAutomation/AutomationMessages.cs
Original file line number Diff line number Diff line change
@@ -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<TapMessage>();
case var s when s == $"{AutomationMessageType}.{nameof(KeyboardMessage)}":
return payload.ToObject<KeyboardMessage>();
case var s when s == $"{AutomationMessageType}.{nameof(SwipeMessage)}":
return payload.ToObject<SwipeMessage>();
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; }
}
}
78 changes: 78 additions & 0 deletions src/TestAppRunner/TestAppRunner/UIAutomation/UIAutomation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MSTestX
{
/// <summary>
/// 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.
/// </summary>
public static class UIAutomation
{
/// <summary>
/// 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.
/// </summary>
public static bool IsConnected => TestAppRunner.ViewModels.TestRunnerVM.Instance.Connection?.IsConnected ?? false;

/// <summary>
/// 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.
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
public static void Tap(int x, int y)
{
SendMessage(new TapMessage { X = x, Y = y });
}

/// <summary>
/// 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.
/// </summary>
/// <param name="fromX"></param>
/// <param name="fromY"></param>
/// <param name="toX"></param>
/// <param name="toY"></param>
/// <param name="durationMs"></param>
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 });
}

/// <summary>
/// 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.
/// </summary>
/// <param name="x">The horizontal coordinate, in pixels, where the long press is performed.</param>
/// <param name="y">The vertical coordinate, in pixels, where the long press is performed.</param>
/// <param name="durationMs">The duration of the long press gesture, in milliseconds. The default is 1000 milliseconds.</param>
public static void SendLongPress(int x, int y, int durationMs = 1000)
{
SendMessage(new SwipeMessage { FromX = x, FromY = y, ToX = x, ToY = y, DurationMs = durationMs });
}

/// <summary>
/// Sends a keyboard input message to the host machine to simulate typing the specified text.
/// </summary>
/// <param name="text"></param>
public static void SendTextInput(string text) => SendMessage(new KeyboardMessage { Key = text });

/// <summary>
/// Sends a keyboard input message to the host machine to simulate typing the specified key.
/// </summary>
/// <param name="key"></param>
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);
}
}
}
1 change: 1 addition & 0 deletions src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading