diff --git a/samples/SampleInteractive-CI.dib b/samples/SampleInteractive-CI.dib new file mode 100644 index 0000000..7dbd7bd --- /dev/null +++ b/samples/SampleInteractive-CI.dib @@ -0,0 +1,28 @@ +#!csharp + +#i "nuget:C:\code\Maui.UITesting\src\packages" +#r "nuget:Redth.Microsoft.Maui.Automation.Interactive,0.10001.0" + +#!markdown + +`dotnet repl --run .\SampleInteractive-CI.dib --input platform="Android" device="Pixel_5_API_31" app="C:\code\Maui.UITesting\samples\SampleMauiApp\bin\Debug\net7.0-android\com.companyname.samplemauiapp-Signed.apk" --output-format trx --output-path .\testresults.trx --exit-after-run` + +#!csharp + +// Pixel_5_API_31 +#!uitest --platform @input:platform --device @input:device --app @input:app + +#!csharp + +await Driver.First(By.AutomationId("entryUsername")) + .InputText("redth"); + +await Driver.First(By.AutomationId("entryPassword")) + .InputText("1234"); + +await Driver.First(By.ContainingText("Login")) + .Tap(); + +#!csharp + +await Driver.RenderScreenshot(); diff --git a/samples/SampleInteractive.dib b/samples/SampleInteractive.dib new file mode 100644 index 0000000..1f6d786 --- /dev/null +++ b/samples/SampleInteractive.dib @@ -0,0 +1,37 @@ +#!pwsh + +Remove-Item -Recurse -Force ~/.nuget/packages/redth.microsoft.maui.automation.interactive +Remove-Item -Recurse -Force ~/.nuget/packages/redth.microsoft.maui.automation +Remove-Item -Recurse -Force ~/.nuget/packages/redth.microsoft.maui.automation.driver + +$pkgVersion="0.10001" +dotnet pack ../src/Core/Core.csproj -p:PackageVersion=$pkgVersion -p:PackageOutputPath=../packages +dotnet pack ../src/Driver/Driver.csproj -p:PackageVersion=$pkgVersion -p:PackageOutputPath=../packages +dotnet pack ../src/Agent/Agent.csproj -p:PackageVersion=$pkgVersion -p:PackageOutputPath=../packages +dotnet pack ../src/Interactive/Interactive.csproj -p:PackageVersion=$pkgVersion -p:PackageOutputPath=../packages + +#!csharp + +#i "nuget:C:\code\Maui.UITesting\src\packages" +#r "nuget:Redth.Microsoft.Maui.Automation.Interactive,0.10001.0" + +#!csharp + +#!uitest --platform Android --device Pixel_5_API_31 --app "C:\code\Maui.UITesting\samples\SampleMauiApp\bin\Debug\net7.0-android\com.companyname.samplemauiapp-Signed.apk" + +#!csharp + +await Driver.Start(); + +await Driver.First(By.AutomationId("entryUsername")) + .InputText("redth"); + +await Driver.First(By.AutomationId("entryPassword")) + .InputText("1234"); + +await Driver.First(By.ContainingText("Login")) + .Tap(); + +#!csharp + +await Driver.RenderScreenshot(); diff --git a/src/Driver/AndroidDriver.cs b/src/Driver/AndroidDriver.cs index a71042f..e2140db 100644 --- a/src/Driver/AndroidDriver.cs +++ b/src/Driver/AndroidDriver.cs @@ -327,10 +327,20 @@ public override Task Backdoor(Platform automationPlatform, string full public override Task Screenshot(string? filename = null) { - var fullFilename = base.GetScreenshotFilename(filename); + var fullFilename = base.GetScreenshotFilename(filename); + var localDir = Path.Combine(Path.GetTempPath(), "adbshellpull"); + Directory.CreateDirectory(localDir); + + var remoteFile = $"/sdcard/{Guid.NewGuid().ToString("N")}.png"; + + Shell($"screencap {remoteFile}"); + + WrapAdbTool(() => Adb.Run("-s", $"\"{Device}\"", "pull", remoteFile, localDir)); + + File.Move(Path.Combine(localDir, Path.GetFileName(remoteFile)), fullFilename, true); + + Shell($"rm {remoteFile}"); - WrapAdbTool(() => - Adb.ScreenCapture(new FileInfo(fullFilename), Device)); return Task.CompletedTask; } diff --git a/src/Driver/AppDriverBuilder.cs b/src/Driver/AppDriverBuilder.cs index 7cd2bd2..d8724ba 100644 --- a/src/Driver/AppDriverBuilder.cs +++ b/src/Driver/AppDriverBuilder.cs @@ -25,8 +25,8 @@ public static AppDriverBuilder WithConfig(string configFile) public readonly IAutomationConfiguration Configuration; - readonly IHostBuilder HostBuilder; - IHost? Host; + protected readonly IHostBuilder HostBuilder; + protected IHost? Host; public AppDriverBuilder() { @@ -82,7 +82,7 @@ public AppDriverBuilder ConfigureLogging(Action configure) return this; } - public IDriver Build() + public virtual IDriver Build() { Host = HostBuilder.Build(); diff --git a/src/Driver/AutomationConfiguration.cs b/src/Driver/AutomationConfiguration.cs index 8fcfafc..89e7b67 100644 --- a/src/Driver/AutomationConfiguration.cs +++ b/src/Driver/AutomationConfiguration.cs @@ -50,9 +50,19 @@ public AutomationConfiguration(string appId, string appFilename, Platform device if (!string.IsNullOrEmpty(device)) Device = device; AutomationPlatform = automationPlatform ?? devicePlatform; - } - - public string? AppAgentAddress + } + + public AutomationConfiguration(string appId, FileInfo appFilename, Platform devicePlatform, string? device = null, Platform? automationPlatform = null) + { + AppFilename = appFilename.FullName; + AppId = appId; + DevicePlatform = devicePlatform; + if (!string.IsNullOrEmpty(device)) + Device = device; + AutomationPlatform = automationPlatform ?? devicePlatform; + } + + public string? AppAgentAddress { get => GetOrDefault(nameof(AppAgentAddress), IPAddress.Loopback.ToString())?.ToString(); set => Set(nameof(AppAgentAddress), value); diff --git a/src/Driver/Xcode.cs b/src/Driver/Xcode.cs new file mode 100644 index 0000000..f1496a5 --- /dev/null +++ b/src/Driver/Xcode.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Maui.Automation.Driver; +using Newtonsoft.Json; + +namespace Microsoft.Maui.Automation.Util; + +public class Xcode +{ + public static List GetDevices(params string[] targetPlatformIdentifiers) + { + var xcode = GetBestXcode(); + + var xcdevice = new FileInfo(Path.Combine(xcode, "Contents/Developer/usr/bin/xcdevice")); + + if (!xcdevice.Exists) + throw new FileNotFoundException(xcdevice.FullName); + var pr = new ProcessRunner(NullLogger.Instance, xcdevice.FullName, "list"); + + var json = string.Join(Environment.NewLine, pr.Output); + + + var xcdevices = JsonConvert.DeserializeObject>(json); + + var tpidevices = xcdevices + .Where(d => (targetPlatformIdentifiers == null || targetPlatformIdentifiers.Length <= 0) + || (targetPlatformIdentifiers.Intersect(d.DotNetPlatforms)?.Any() ?? false)); + + var filteredDevices = tpidevices + .Select(d => new DeviceData + { + IsEmulator = d.Simulator, + IsRunning = false, + Name = d.Name, + Details = d.ModelName + " (" + d.Architecture + ")", + Platforms = d.DotNetPlatforms, + Serial = d.Identifier, + Version = d.OperatingSystemVersion + }); + + return filteredDevices.ToList(); + } + + internal static string GetBestXcode() + { + var selected = GetSelectedXCodePath(); + + if (!string.IsNullOrEmpty(selected)) + return selected; + + return FindXCodeInstalls()?.FirstOrDefault(); + } + + static string? GetSelectedXCodePath() + { + var r = new ProcessRunner(NullLogger.Instance, "/usr/bin/xcode-select", "-p"); + var xcodeSelectedPath = string.Join(Environment.NewLine, r.Output)?.Trim(); + + if (!string.IsNullOrEmpty(xcodeSelectedPath)) + { + var infoPlist = Path.Combine(xcodeSelectedPath, "..", "Info.plist"); + if (File.Exists(infoPlist)) + { + var info = GetXcodeInfo( + Path.GetFullPath( + Path.Combine(xcodeSelectedPath, "..", "..")), true); + + if (info != null) + return info?.Path; + } + } + + return null; + } + + static readonly string[] LikelyPaths = new[] + { + "/Applications/Xcode.app", + "/Applications/Xcode-beta.app", + }; + + static IEnumerable FindXCodeInstalls() + { + foreach (var p in LikelyPaths) + { + var i = GetXcodeInfo(p, false)?.Path; + if (i != null) + yield return i; + } + } + + static (string Path, bool Selected)? GetXcodeInfo(string path, bool selected) + { + var versionPlist = Path.Combine(path, "Contents", "version.plist"); + + if (File.Exists(versionPlist)) + { + return (path, selected); + } + else + { + var infoPlist = Path.Combine(path, "Contents", "Info.plist"); + + if (File.Exists(infoPlist)) + { + return (path, selected); + } + } + return null; + } +} + +public class XcDevice +{ + + public const string PlatformMacOsx = "com.apple.platform.macosx"; + public const string PlatformiPhoneSimulator = "com.apple.platform.iphonesimulator"; + public const string PlatformAppleTvSimulator = "com.apple.platform.appletvsimulator"; + public const string PlatformAppleTv = "com.apple.platform.appletvos"; + public const string PlatformWatchSimulator = "com.apple.platform.watchsimulator"; + public const string PlatformiPhone = "com.apple.platform.iphoneos"; + public const string PlatformWatch = "com.apple.platform.watchos"; + + [JsonProperty("simulator")] + public bool Simulator { get; set; } + + [JsonProperty("operatingSystemVersion")] + public string OperatingSystemVersion { get; set; } + + [JsonProperty("available")] + public bool Available { get; set; } + + [JsonProperty("platform")] + public string Platform { get; set; } + + public bool IsiOS + => !string.IsNullOrEmpty(Platform) && (Platform.Equals(PlatformiPhone) || Platform.Equals(PlatformiPhoneSimulator)); + public bool IsTvOS + => !string.IsNullOrEmpty(Platform) && (Platform.Equals(PlatformAppleTv) || Platform.Equals(PlatformAppleTvSimulator)); + public bool IsWatchOS + => !string.IsNullOrEmpty(Platform) && (Platform.Equals(PlatformWatch) || Platform.Equals(PlatformWatchSimulator)); + public bool IsOsx + => !string.IsNullOrEmpty(Platform) && Platform.Equals(PlatformMacOsx); + + public string[] DotNetPlatforms + => Platform switch + { + PlatformiPhone => new[] { "ios" }, + PlatformiPhoneSimulator => new[] { "ios" }, + PlatformAppleTv => new[] { "tvos" }, + PlatformAppleTvSimulator => new[] { "tvos" }, + PlatformWatch => new[] { "watchos" }, + PlatformWatchSimulator => new[] { "watchos" }, + PlatformMacOsx => new[] { "macos", "maccatalyst" }, + _ => new string[0] + }; + + [JsonProperty("modelCode")] + public string ModelCode { get; set; } + + [JsonProperty("identifier")] + public string Identifier { get; set; } + + [JsonProperty("architecture")] + public string Architecture { get; set; } + + public string RuntimeIdentifier + => Architecture switch + { + _ => "iossimulator-x64" + }; + + [JsonProperty("modelUTI")] + public string ModelUTI { get; set; } + + [JsonProperty("modelName")] + public string ModelName { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("interface")] + public string Interface { get; set; } +} + +public class DeviceData +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("details")] + public string Details { get; set; } + + [JsonProperty("serial")] + public string Serial { get; set; } + + [JsonProperty("platforms")] + public string[] Platforms { get; set; } + + [JsonProperty("version")] + public string Version { get; set; } + + [JsonProperty("isEmulator")] + public bool IsEmulator { get; set; } + + [JsonProperty("isRunning")] + public bool IsRunning { get; set; } + + [JsonProperty("rid")] + public string RuntimeIdentifier { get; set; } +} \ No newline at end of file diff --git a/src/Interactive/AutomationExtension.cs b/src/Interactive/AutomationExtension.cs index e465262..e894c5d 100644 --- a/src/Interactive/AutomationExtension.cs +++ b/src/Interactive/AutomationExtension.cs @@ -1,87 +1,277 @@ using System.CommandLine; using System.CommandLine.Binding; using Microsoft.DotNet.Interactive; - -namespace Microsoft.Maui.Automation.Interactive; +using Microsoft.DotNet.Interactive.CSharp; +using Microsoft.Maui.Automation.Driver; +using AndroidSdk; +using Microsoft.Maui.Automation.Util; +using Microsoft.DotNet.Interactive.Formatting; +using static Microsoft.DotNet.Interactive.Formatting.PocketViewTags; +using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.DotNet.Interactive.Commands; +using static Pocket.Logger; +using Pocket; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Maui.Automation.Interactive; public class AutomationExtensions : IKernelExtension -{ - Driver.AppDriver? driver; +{ + static TaskCompletionSource>> tcsDevices = new(); + static Task>> Devices + => tcsDevices.Task; - public Task OnLoadAsync(Kernel kernel) - { + public async Task OnLoadAsync(Kernel kernel) + { + using var operation = Log.OnEnterAndExit(); + + var startedLoadingDevices = false; + + var installedOnKernels = new List(); + var candidateKernels = kernel.FindKernels(k => k is CSharpKernel).Cast(); + foreach (var csKernel in candidateKernels) + { + //if (csKernel.Directives.Any(d => d.Name.Equals("#!uitest", StringComparison.OrdinalIgnoreCase))) + //{ + if (!startedLoadingDevices) + { + startedLoadingDevices = true; + operation.Info("Loading devices..."); + var _ = LoadDevices().ContinueWith(t => tcsDevices.TrySetResult(t.Result)); + } + + await ConfigureKernelDirective(csKernel); + installedOnKernels.Add(csKernel.Name); + //} + } + + if (installedOnKernels.Count > 0 && KernelInvocationContext.Current is { } context) + { + PocketView view = div( + code("Maui.UITesting"), + $" is loaded. It adds commands for UI Testing/Automation. Available on {string.Join(", ", installedOnKernels)}." + ); + + context.Display(view); + + PocketView view2 = div( + "Use the command: ", + code("#!uitest -h") + ); + + context.Display(view2); + } - var platformOption = new Option( - new[] { "--platform" }, - "The device platform to test on.") - { - Arity = ArgumentArity.ExactlyOne - }; - var appIdOption = new Option( - new[] { "--app-id" }, - "The application id (bundle id, package id, etc) to test.") - { - Arity = ArgumentArity.ExactlyOne - }; - var appOption = new Option( - new[] { "--app" }, - "The application file (.app, .apk, etc.) to test.") - { - Arity = ArgumentArity.ExactlyOne - }; - var automationPlatformOption = new Option( - new[] { "--automation-platform" }, - "The automation platform to interact with, if different than the device platform (ie: Maui).") - { - Arity = ArgumentArity.ZeroOrOne - }; - var deviceOption = new Option( - new[] { "--device" }, - getDefaultValue: () => string.Empty, - description: "Device ID to test on (eg: ADB Serial for an android device, UDID of emulator for iOS/tvOS/ipadOS).") + } + + async Task ConfigureKernelDirective(CSharpKernel csharpKernel) + { + using var operation = Log.OnEnterAndExit(); + + var platformOption = new Option( + new[] { "--platform" }, + () => Platform.Maui, + "The device platform to test on.") + { + Arity = ArgumentArity.ExactlyOne + }; + + var appIdOption = new Option( + new[] { "--app-id" }, + "The application id (bundle id, package id, etc) to test.") + { + Arity = ArgumentArity.ZeroOrOne + }; + + var appOption = new Option( + new[] { "--app" }, + "The application file (.app, .apk, etc.) to test.") + { + Arity = ArgumentArity.ZeroOrOne, + }.ExistingOnly(); + + var automationPlatformOption = new Option( + new[] { "--automation-platform" }, + () => Platform.Maui, + "The automation platform to interact with, if different than the device platform (ie: Maui).") + { + Arity = ArgumentArity.ZeroOrOne + }; + var deviceOption = new Option( + new[] { "--device" }, + getDefaultValue: () => string.Empty, + description: + "Device ID to test on (eg: ADB Serial for an android device, UDID of emulator for iOS/tvOS/ipadOS).") + { + Arity = ArgumentArity.ZeroOrOne + }; + + + var allDevices = await Devices; + + deviceOption.AddCompletions(context => + context.ParseResult.GetValueForOption(automationPlatformOption) switch + { + Platform.Maui => allDevices.Values.SelectMany(v => v), + Platform.Ios => allDevices["ios"], + Platform.Tvos => allDevices["tvos"], + Platform.Maccatalyst => allDevices["macos"], + Platform.Macos => allDevices["macos"], + Platform.Winappsdk => allDevices["windows"], + _ => Enumerable.Empty() + }); + + var nameOption = new Option( + new[] { "--name" }, + getDefaultValue: () => "Driver", + description: + "Name for the local variable.") + { + Arity = ArgumentArity.ZeroOrOne + }; + + + var testCommand = new Command("#!uitest", "Starts a UI Testing Driver session") { - Arity = ArgumentArity.ZeroOrOne - }; - - - var testOption = new Command("#!uitest", "Starts a UI Testing session") - { - platformOption, - automationPlatformOption, - appIdOption, - appOption, - deviceOption - }; - - testOption.SetHandler( - async (Platform platform, Platform automationPlatform, string appId, string app, string device) => - { - - if (driver is not null) - { - await driver.StopApp(); - await driver.ClearAppState(); - driver.Dispose(); - driver = null; - } - - driver = new Driver.AppDriver( - new Driver.AutomationConfiguration( - appId, app, platform, device, automationPlatform)); - - await driver.InstallApp(); - - //KernelInvocationContext.Current.Display(SvgClock.DrawSvgClock(hour, minute, second)); - }, platformOption, automationPlatformOption, appIdOption, appOption, - deviceOption); - - kernel.AddDirective(testOption); + deviceOption, + nameOption + }; + + testCommand.SetHandler( + async (Platform platform, Platform automationPlatform, string appId, FileInfo? app, string device, string name) => + { + // if there is an already used dirver with such name dispose + if (csharpKernel.TryGetValue(name, out IDriver oldDriver) && oldDriver is { }) + { + await DisposeAppDriver(oldDriver); + } + + var builder = new AppDriverBuilder() + .ConfigureLogging(l => l.ClearProviders()) + .DevicePlatform(platform) + .AutomationPlatform(automationPlatform); + + if (app is not null) + builder = builder.AppFilename(app.FullName); + if (!string.IsNullOrEmpty(appId)) + builder = builder.AppId(appId); + if (!string.IsNullOrEmpty(device)) + builder = builder.Device(device); + + var driver = builder.Build(); + + csharpKernel.RegisterForDisposal(async () => + { + await DisposeAppDriver(driver); + }); + + await driver.Start(); + + await csharpKernel.SetValueAsync(name, driver); + }, + platformOption, + automationPlatformOption, + appIdOption, + appOption, + deviceOption, + nameOption); + + csharpKernel.AddDirective(testCommand); + csharpKernel.DeferCommand(new SubmitCode("using Microsoft.Maui.Automation;using Microsoft.Maui.Automation.Driver;", csharpKernel.Name)); + } - return Task.CompletedTask; + Task DisposeAppDriver(IDriver driver) + { + try + { + driver.Dispose(); + } + catch + { } + + return Task.CompletedTask; + } + + async Task>> LoadDevices() + { + var devices = new ConcurrentDictionary>(); + devices["android"] = new(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + devices["ios"] = new(); + devices["tvos"] = new(); + devices["macos"] = new(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + devices["windows"] = new() { "Windows" }; + } + + var deviceTasks = new List(); + deviceTasks.Add(Task.Run(() => AddAndroidDevices(devices["android"]))); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + deviceTasks.Add(Task.Run(() => AddAppleDevices("ios", devices["ios"]))); + deviceTasks.Add(Task.Run(() => AddAppleDevices("tvos", devices["tvos"]))); + deviceTasks.Add(Task.Run(() => AddAppleDevices("macos", devices["macos"]))); + } + + await Task.WhenAll(deviceTasks).ConfigureAwait(false); + + return devices; + } + + void AddAndroidDevices(IList devices) + { + try + { + var adb = new Adb(); + var adbDevices = adb.GetDevices(); + foreach (var device in adbDevices) + devices.Add(device.Serial); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + + try + { + var avd = new AvdManager(); + var avds = avd.ListAvds(); + foreach (var device in avds) + devices.Add(device.Name); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + void AddAppleDevices(string platform, IList devices) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + try + { + var appleDevices = Xcode.GetDevices(platform).Select(d => d.Name); + foreach (var device in appleDevices) + devices.Add(device); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } } } diff --git a/src/Interactive/DriverExtensions.cs b/src/Interactive/DriverExtensions.cs new file mode 100644 index 0000000..b35f368 --- /dev/null +++ b/src/Interactive/DriverExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.DotNet.Interactive; +using Microsoft.DotNet.Interactive.Formatting; +using Microsoft.Maui.Automation.Driver; + +namespace Microsoft.Maui.Automation.Driver; + +public static class DriverExtensions +{ + public static async Task RenderScreenshot(this IDriver driver) + { + var file = Path.GetTempFileName(); + await driver.Screenshot(file); + + if (KernelInvocationContext.Current is { } context) + { + var ext = Path.GetExtension(file)?.ToLowerInvariant()?.TrimStart('.')?.Trim(); + + var mime = ext switch + { + "jpg" => "image/jpeg", + "jpeg" => "image/jpeg", + "png" => "image/png", + _ => "image/png" + }; + + var data = Convert.ToBase64String(File.ReadAllBytes(file)); + + PocketView img = PocketViewTags.img[src: $"data:{mime};base64,{data}", style: "max-width: 1000px; max-height: 1000px;"]; + + context.Display(img, "text/html"); + } + + + } +} + diff --git a/src/Interactive/Interactive.csproj b/src/Interactive/Interactive.csproj index 597b592..a101499 100644 --- a/src/Interactive/Interactive.csproj +++ b/src/Interactive/Interactive.csproj @@ -4,7 +4,7 @@ net6.0 enable enable - Microsoft.Maui.Automation.Interactive + Redth.Microsoft.Maui.Automation.Interactive Redth.Microsoft.Maui.Automation.Interactive @@ -13,18 +13,16 @@ - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - - + - - diff --git a/src/Interactive/Package.props b/src/Interactive/Package.props deleted file mode 100644 index 441c1e2..0000000 --- a/src/Interactive/Package.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/Interactive/extension.dib b/src/Interactive/extension.dib deleted file mode 100644 index 1367e26..0000000 --- a/src/Interactive/extension.dib +++ /dev/null @@ -1,4 +0,0 @@ -#!csharp - -using Microsoft.Maui.Automation; -using Microsoft.Maui.Automation.Driver;