File: CreateXHarnessAndroidWorkItems.cs
Web Access
Project: src\src\Microsoft.DotNet.Helix\Sdk\Microsoft.DotNet.Helix.Sdk.csproj (Microsoft.DotNet.Helix.Sdk)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Arcade.Common;
using Microsoft.Build.Framework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
 
namespace Microsoft.DotNet.Helix.Sdk
{
    /// <summary>
    /// MSBuild custom task to create HelixWorkItems for provided Android application packages.
    /// </summary>
    public class CreateXHarnessAndroidWorkItems : XHarnessTaskBase
    {
        public static class MetadataNames
        {
            public const string Arguments = "Arguments";
            public const string AndroidInstrumentationName = "AndroidInstrumentationName";
            public const string DeviceOutputPath = "DeviceOutputPath";
            public const string AndroidPackageName = "AndroidPackageName";
            public const string ApkPath = "ApkPath";
        }
 
        private const string PosixAndroidScript = "xharness-helix-job.android.sh";
        private const string NonPosixAndroidScript = "xharness-helix-job.android.ps1";
        private const string NonPosixAndroidWrapperScript = "xharness-runner.android.ps1";
 
        /// <summary>
        /// Boolean true if this is a posix shell, false if not.
        /// This does not need to be set by a user; it is automatically determined in Microsoft.DotNet.Helix.Sdk.MonoQueue.targets
        /// </summary>
        [Required]
        public bool IsPosixShell { get; set; }
 
        /// <summary>
        /// An array of one or more paths to application packages (.apk for Android)
        /// that will be used to create Helix work items.
        /// </summary>
        public ITaskItem[] Apks { get; set; }
 
        public override void ConfigureServices(IServiceCollection collection)
        {
            collection.TryAddTransient<IZipArchiveManager, ZipArchiveManager>();
            collection.TryAddTransient<IFileSystem, FileSystem>();
            collection.TryAddSingleton(Log);
        }
 
        /// <summary>
        /// The main method of this MSBuild task which calls the asynchronous execution method and
        /// collates logged errors in order to determine the success of HelixWorkItems
        /// </summary>
        /// <returns>A boolean value indicating the success of HelixWorkItem creation</returns>
        public bool ExecuteTask(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem)
        {
            var tasks = Apks.Select(apk => PrepareWorkItem(zipArchiveManager, fileSystem, apk));
 
            WorkItems = Task.WhenAll(tasks).GetAwaiter().GetResult().Where(wi => wi != null).ToArray();
 
            return !Log.HasLoggedErrors;
        }
 
        /// <summary>
        /// Prepares HelixWorkItem that can run on a device (currently Android or iOS) using XHarness
        /// </summary>
        /// <param name="appPackage">Path to application package</param>
        /// <returns>An ITaskItem instance representing the prepared HelixWorkItem.</returns>
        private async Task<ITaskItem> PrepareWorkItem(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem, ITaskItem appPackage)
        {
            var (workItemName, apkPath) = GetNameAndPath(appPackage, MetadataNames.ApkPath, fileSystem);
 
            if (!fileSystem.FileExists(apkPath))
            {
                Log.LogError($"App package not found in {apkPath}");
                return null;
            }
 
            string extension = fileSystem.GetExtension(apkPath).ToLowerInvariant();
            bool isAlreadyArchived = (extension == ".zip");
 
            if (!isAlreadyArchived && extension != ".apk")
            {
                Log.LogError($"Unsupported payload file `{fileSystem.GetFileName(apkPath)}`; expecting .apk or .zip");
                return null;
            }
 
            var (testTimeout, workItemTimeout, expectedExitCode, customCommands) = ParseMetadata(appPackage);
            appPackage.TryGetMetadata(MetadataNames.AndroidPackageName, out string androidPackageName);
 
            if (customCommands == null)
            {
                // When no user commands are specified, we add the default `android test ...` command
                customCommands = GetDefaultCommand(appPackage, expectedExitCode);
 
                // Validation of any metadata specific to Android stuff goes here
                if (string.IsNullOrEmpty(androidPackageName))
                {
                    Log.LogError($"{MetadataNames.AndroidPackageName} metadata must be specified when not supplying custom commands");
                    return null;
                }
            }
 
            string apkName = Path.GetFileName(apkPath);
            if (isAlreadyArchived) {
                apkName = apkName.Replace(".zip", ".apk");
            }
 
            string command = GetHelixCommand(appPackage, apkName, androidPackageName, workItemTimeout, testTimeout, expectedExitCode);
 
            if (!IsPosixShell)
            {
                // For windows, we need to add a .ps1 header to turn the script into a cmdlet
                customCommands = WrapCustomCommands(customCommands);
            }
 
            string workItemZip = await CreatePayloadArchive(
                zipArchiveManager,
                fileSystem,
                workItemName,
                isAlreadyArchived,
                IsPosixShell,
                apkPath,
                customCommands,
                new[]
                {
                    // WorkItem payloads of APKs can be reused if sent to multiple queues at once,
                    // so we'll always include both scripts (very small)
                    PosixAndroidScript, NonPosixAndroidScript
                });
 
            return CreateTaskItem(workItemName, workItemZip, command, workItemTimeout);
        }
 
        private string GetDefaultCommand(ITaskItem appPackage, int expectedExitCode)
        {
            appPackage.TryGetMetadata(MetadataNames.Arguments, out string extraArguments);
 
            var exitCodeArg = expectedExitCode != 0 ? $"--expected-exit-code $expected_exit_code" : string.Empty;
            var passthroughArgs = !string.IsNullOrEmpty(AppArguments) ? $" -- {AppArguments}" : string.Empty;
 
            var instrumentationArg = appPackage.TryGetMetadata(MetadataNames.AndroidInstrumentationName, out string androidInstrumentationName)
                ? $"--instrumentation \"{androidInstrumentationName}\""
                : string.Empty;
 
            var devOutArg = appPackage.TryGetMetadata(MetadataNames.DeviceOutputPath, out string deviceOutputPath)
                ? $"--dev-out \"{deviceOutputPath}\""
                : string.Empty;
 
            // In case user didn't specify custom commands, we use our default one
            return "xharness android test " +
                "--app \"$app\" " +
                "--output-directory \"$output_directory\" " +
                "--timeout \"$timeout\" " +
                "--package-name \"$package_name\" " +
                " -v " +
                $"{ devOutArg } { instrumentationArg } { exitCodeArg } { extraArguments } { passthroughArgs }";
        }
 
        private string GetHelixCommand(
            ITaskItem appPackage,
            string apkName,
            string androidPackageName,
            TimeSpan workItemTimeout,
            TimeSpan xHarnessTimeout,
            int expectedExitCode)
        {
            appPackage.TryGetMetadata(MetadataNames.AndroidInstrumentationName, out string androidInstrumentationName);
            appPackage.TryGetMetadata(MetadataNames.DeviceOutputPath, out string deviceOutputPath);
 
            string wrapperScriptName = IsPosixShell ? PosixAndroidScript : NonPosixAndroidScript;
            string xharnessHelixWrapperScript = IsPosixShell ? $"chmod +x ./{wrapperScriptName} && ./{wrapperScriptName}"
                                                             : $"powershell -ExecutionPolicy ByPass -NoProfile -File \"{wrapperScriptName}\"";
 
            // We either call .ps1 or .sh so we need to format the arguments well (PS has -argument, bash has --argument)
            string dash = IsPosixShell ? "--" : "-";
            string xharnessRunCommand = $"{xharnessHelixWrapperScript} " +
                $"{dash}app \"{apkName}\" " +
                $"{dash}command_timeout {(int)workItemTimeout.TotalSeconds} " +
                $"{dash}timeout \"{xHarnessTimeout}\" " +
                $"{dash}package_name \"{androidPackageName}\" " +
                (expectedExitCode != 0 ? $" {dash}expected_exit_code \"{expectedExitCode}\" " : string.Empty) +
                (string.IsNullOrEmpty(deviceOutputPath) ? string.Empty : $"{dash}device_output_path \"{deviceOutputPath}\" ") +
                (string.IsNullOrEmpty(androidInstrumentationName) ? string.Empty : $"{dash}instrumentation \"{androidInstrumentationName}\" ");
 
            Log.LogMessage(MessageImportance.Low, $"Generated XHarness command: {xharnessRunCommand}");
 
            return xharnessRunCommand;
        }
 
        private static string WrapCustomCommands(string customCommands)
        {
            using Stream stream = ZipArchiveManager.GetResourceFileContent<CreateXHarnessAndroidWorkItems>(
                ScriptNamespace + NonPosixAndroidWrapperScript);
            using StreamReader reader = new(stream);
            return reader.ReadToEnd().Replace("<#%%USER COMMANDS%%#>", customCommands);
        }
    }
}