Skip to content

Commit fe4969a

Browse files
author
Morten Nielsen
committed
Add ability to perform UI Automation via connected console test runner
1 parent 5da5e6a commit fe4969a

File tree

8 files changed

+180
-7
lines changed

8 files changed

+180
-7
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace MSTestX.Console.Adb
8+
{
9+
internal partial class AdbClient
10+
{
11+
// See https://commandmasters.com/commands/input-android/ for reference to ADB input commands.
12+
13+
public async Task PerformUIAutomationAction(UIAutomationMessage message)
14+
{
15+
if (message is TapMessage tap)
16+
{
17+
// send a tap command to the device over ADB.
18+
await SendShellCommandAsync($"adb shell input tap {tap.X} {tap.Y}", "deviceId");
19+
}
20+
if (message is SwipeMessage swipe)
21+
{
22+
// send a swipe command to the device over ADB.
23+
await SendShellCommandAsync($"adb shell input swipe {swipe.FromX} {swipe.FromY} {swipe.ToX} {swipe.ToY} {swipe.DurationMs}", "deviceId");
24+
}
25+
else if (message is KeyboardMessage keyboard)
26+
{
27+
// TODO: Send modifiers
28+
if(keyboard.Key.Length == 1)
29+
await SendShellCommandAsync($"adb shell input keyevent {KeyToKeyEventCode(keyboard.Key[0])}", "deviceId");
30+
else
31+
await SendShellCommandAsync($"adb shell input text \"{keyboard.Key.Replace(" ", "%20")}\"", "deviceId");
32+
}
33+
}
34+
35+
private static int KeyToKeyEventCode(char key)
36+
{
37+
// This is a very simplified mapping. A real implementation would need to handle more keys and modifiers.
38+
if (char.IsLetterOrDigit(key))
39+
{
40+
return char.ToUpper(key) - 'A' + 29; // KeyEvent.KEYCODE_A starts at 29
41+
}
42+
switch (key)
43+
{
44+
case ' ':
45+
return 62; // KeyEvent.KEYCODE_SPACE
46+
case '\n':
47+
return 66; // KeyEvent.KEYCODE_ENTER
48+
default:
49+
throw new NotSupportedException($"Key '{key}' is not supported.");
50+
}
51+
}
52+
}
53+
}

Automation/MSTestX.Console/Adb/AdbClient.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace MSTestX.Console.Adb
1414
{
1515
// Good command-reference overview here: https://android.googlesource.com/platform/packages/modules/adb/+/HEAD/SERVICES.TXT
1616
// ADB Source and overview: https://android.googlesource.com/platform/packages/modules/adb/
17-
internal class AdbClient
17+
internal partial class AdbClient
1818
{
1919
private int port;
2020

@@ -173,5 +173,5 @@ private static async Task WriteCommandToSocket(Socket s, string command)
173173
}
174174
System.Diagnostics.Debug.WriteLine($"OKAY");
175175
}
176-
}
176+
}
177177
}

Automation/MSTestX.Console/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ private static async Task OnApplicationLaunched(System.Net.IPEndPoint? endpoint
397397
return;
398398
appLaunchDetected = true;
399399
int result = 0;
400-
var runner = new TestRunner(endpoint);
400+
var runner = new TestRunner(endpoint, client);
401401
try
402402
{
403403
await Task.Delay(5000); //Give app some time to start up

Automation/MSTestX.Console/TestRunner.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414

1515
namespace MSTestX.Console
1616
{
17-
public class TestRunner : IDisposable
17+
internal class TestRunner : IDisposable
1818
{
1919
private SocketCommunicationManager socket;
2020
private System.Net.IPEndPoint _endpoint;
21+
private Adb.AdbClient? _adbClient;
2122

22-
public TestRunner(System.Net.IPEndPoint endpoint = null)
23+
public TestRunner(System.Net.IPEndPoint endpoint = null, Adb.AdbClient adbClient = null)
2324
{
2425
_endpoint = endpoint;
26+
_adbClient = adbClient;
2527
}
2628

2729
public async Task RunTests(string outputFilename, string settingsXml, CancellationToken cancellationToken)
@@ -106,8 +108,22 @@ private async Task RunTestsInternal(string outputFilename, string settingsXml, T
106108
{
107109
continue;
108110
}
109-
110-
if (msg.MessageType == MessageType.TestHostLaunched)
111+
if (msg.MessageType.StartsWith(UIAutomationMessage.AutomationMessageType))
112+
{
113+
var uia = UIAutomationMessage.FromMessageType(msg.MessageType, msg.Payload);
114+
if (uia is not null)
115+
{
116+
if (_adbClient is not null)
117+
{
118+
_ = _adbClient.PerformUIAutomationAction(uia);
119+
}
120+
else
121+
{
122+
System.Console.WriteLine("Cannot send UI Automation message to device.");
123+
}
124+
}
125+
}
126+
else if (msg.MessageType == MessageType.TestHostLaunched)
111127
{
112128
var thl = JsonDataSerializer.Instance.DeserializePayload<TestHostLaunchedPayload>(msg);
113129
pid = thl.ProcessId;

src/TestAppRunner/TestAppRunner/TestAdapterConnection.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ internal class TestAdapterConnection
1717
private int port;
1818
private System.Threading.Thread? messageLoopThread;
1919

20+
public bool IsConnected => isConnected;
21+
2022
public TestAdapterConnection(int port)
2123
{
2224
this.port = port;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#nullable enable
2+
using System;
3+
using System.Collections.Generic;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
using System.Threading.Tasks;
8+
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
9+
using Newtonsoft.Json;
10+
11+
namespace MSTestX
12+
{
13+
internal class UIAutomationMessage
14+
{
15+
internal static string AutomationMessageType => "UIAutomation";
16+
17+
public string MessageType => $"{AutomationMessageType}.{GetType().Name}";
18+
19+
public static UIAutomationMessage? FromMessageType(string messageType, Newtonsoft.Json.Linq.JToken payload)
20+
{
21+
switch (messageType)
22+
{
23+
case var s when s == $"{AutomationMessageType}.{nameof(TapMessage)}":
24+
return payload.ToObject<TapMessage>();
25+
case var s when s == $"{AutomationMessageType}.{nameof(KeyboardMessage)}":
26+
return payload.ToObject<KeyboardMessage>();
27+
case var s when s == $"{AutomationMessageType}.{nameof(SwipeMessage)}":
28+
return payload.ToObject<SwipeMessage>();
29+
default:
30+
throw new InvalidOperationException($"Unknown message type: {messageType}");
31+
}
32+
}
33+
}
34+
35+
internal class TapMessage : UIAutomationMessage
36+
{
37+
public int X { get; init; }
38+
public int Y { get; init; }
39+
}
40+
41+
internal class SwipeMessage : UIAutomationMessage
42+
{
43+
public int FromX { get; init; }
44+
public int FromY { get; init; }
45+
public int ToX { get; init; }
46+
public int ToY { get; init; }
47+
public int DurationMs { get; init; }
48+
}
49+
50+
internal class KeyboardMessage : UIAutomationMessage
51+
{
52+
public string? Key { get; set; }
53+
54+
public string[]? Modifiers { get; set; }
55+
}
56+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace MSTestX
8+
{
9+
/// <summary>
10+
/// Allows the unit test to reach out to the test runner and send UI automation messages to be performed by the host machine.
11+
/// 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.
12+
/// </summary>
13+
public static class UIAutomation
14+
{
15+
/// <summary>
16+
/// 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.
17+
/// </summary>
18+
public static bool IsConnected => TestAppRunner.ViewModels.TestRunnerVM.Instance.Connection?.IsConnected ?? false;
19+
20+
public static void Tap(int x, int y)
21+
{
22+
SendMessage(new TapMessage { X = x, Y = y });
23+
}
24+
25+
public static void SendSwipe(int fromX, int fromY, int toX, int toY, int durationMs = 500)
26+
{
27+
SendMessage(new SwipeMessage { FromX = fromX, FromY = fromY, ToX = toX, ToY = toY, DurationMs = durationMs });
28+
}
29+
30+
public static void SendLongPress(int x, int y, int durationMs = 1000)
31+
{
32+
SendMessage(new SwipeMessage { FromX = x, FromY = y, ToX = x, ToY = y, DurationMs = durationMs });
33+
}
34+
35+
public void SendTextInput(string text) => SendMessage(new KeyboardMessage { Key = text });
36+
37+
public static void SendKeyboardInput(char key) => SendMessage(new KeyboardMessage { Key = key.ToString() });
38+
39+
private static void SendMessage(UIAutomationMessage message)
40+
{
41+
if (!IsConnected) throw new InvalidOperationException("Test Runner is not connected to host runner");
42+
TestAppRunner.ViewModels.TestRunnerVM.Instance.Connection.SendMessage(message.MessageType, message);
43+
}
44+
}
45+
}

src/TestAppRunner/TestAppRunner/ViewModels/TestRunnerVM.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal class TestRunnerVM : VMBase, ITestExecutionRecorder
3232
private System.IO.StreamWriter logOutput;
3333
private TrxWriter trxWriter;
3434
private TestAdapterConnection connection;
35+
internal TestAdapterConnection Connection => connection;
3536

3637
private static TestRunnerVM _Instance;
3738

0 commit comments

Comments
 (0)