File: CreateXHarnessAppleWorkItems.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 iOS app bundle paths.
    /// </summary>
    public class CreateXHarnessAppleWorkItems : XHarnessTaskBase
    {
        public const string iOSTargetName = "ios-device";
        public const string tvOSTargetName = "tvos-device";
 
        public static class MetadataNames
        {
            public const string Target = "TestTarget";
            public const string LaunchTimeout = "LaunchTimeout";
            public const string IncludesTestRunner = "IncludesTestRunner";
            public const string ResetSimulator = "ResetSimulator";
            public const string AppBundlePath = "AppBundlePath";
        }
 
        private const string EntryPointScript = "xharness-helix-job.apple.sh";
        private const string RunnerScript = "xharness-runner.apple.sh";
 
        // We have a more aggressive timeout towards simulators which tend to slow down until installation takes 20 minutes and the machine needs a reboot
        // For this reason, it's better to be aggressive and detect a slower machine sooner
        private static readonly TimeSpan s_defaultSimulatorLaunchTimeout = TimeSpan.FromMinutes(6);
        private static readonly TimeSpan s_defaultDeviceLaunchTimeout = TimeSpan.FromMinutes(5);
 
        /// <summary>
        /// An array of one or more paths to iOS/tvOS app bundles (folders ending with ".app" usually)
        /// that will be used to create Helix work items.
        /// </summary>
        [Required]
        public ITaskItem[] AppBundles { get; set; }
 
        /// <summary>
        /// Xcode version to use, e.g. 11.4 or 12.5_beta3.
        /// </summary>
        public string XcodeVersion { get; set; }
 
        /// <summary>
        /// URL template to get the provisioning profile that will be used to sign the app from (in case of real device targets).
        /// The URL is a template in the following format:
        /// https://storage.azure.com/signing/NET_Apple_Development_{PLATFORM}.mobileprovision
        /// </summary>
        public string ProvisioningProfileUrl { get; set; }
 
        /// <summary>
        /// Path where we can store intermediate files.
        /// </summary>
        public string TmpDir { get; set; }
 
        public override void ConfigureServices(IServiceCollection collection)
        {
            collection.TryAddProvisioningProfileProvider(ProvisioningProfileUrl, TmpDir);
            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(
            IProvisioningProfileProvider provisioningProfileProvider,
            IZipArchiveManager zipArchiveManager,
            IFileSystem fileSystem)
        {
            var tasks = AppBundles.Select(bundle => PrepareWorkItem(zipArchiveManager, fileSystem, provisioningProfileProvider, bundle));
 
            WorkItems = Task.WhenAll(tasks).GetAwaiter().GetResult().Where(wi => wi != null).ToArray();
 
            return !Log.HasLoggedErrors;
        }
 
        /// <summary>
        /// Prepares HelixWorkItem that can run on an iOS device using XHarness
        /// </summary>
        /// <param name="appFolderPath">Path to application package</param>
        /// <returns>An ITaskItem instance representing the prepared HelixWorkItem.</returns>
        private async Task<ITaskItem> PrepareWorkItem(
            IZipArchiveManager zipArchiveManager,
            IFileSystem fileSystem,
            IProvisioningProfileProvider provisioningProfileProvider,
            ITaskItem appBundleItem)
        {
            var (workItemName, appFolderPath) = GetNameAndPath(appBundleItem, MetadataNames.AppBundlePath, fileSystem);
 
            appFolderPath = appFolderPath.TrimEnd(Path.DirectorySeparatorChar);
 
            bool isAlreadyArchived = appFolderPath.EndsWith(".zip");
            if (isAlreadyArchived && workItemName.EndsWith(".app"))
            {
                // If someone named the zip something.app.zip, we want both gone
                workItemName = workItemName.Substring(0, workItemName.Length - 4);
            }
 
            if (!ValidateAppBundlePath(fileSystem, appFolderPath, isAlreadyArchived))
            {
                Log.LogError($"App bundle not found in {appFolderPath}");
                return null;
            }
 
            // If we are re-using one .zip for multiple work items, we need to copy it to a new location
            // because we will be changing the contents (we assume we don't mind otherwise)
            if (isAlreadyArchived && appBundleItem.TryGetMetadata(MetadataNames.AppBundlePath, out string metadata) && !string.IsNullOrEmpty(metadata))
            {
                string appFolderDirectory = fileSystem.GetDirectoryName(appFolderPath);
                string fileName = $"xharness-payload-{workItemName.ToLowerInvariant()}.zip";
                string archiveCopyPath = fileSystem.PathCombine(appFolderDirectory, fileName);
                fileSystem.CopyFile(appFolderPath, archiveCopyPath, overwrite: true);
                appFolderPath = archiveCopyPath;
            }
            
            var (testTimeout, workItemTimeout, expectedExitCode, customCommands) = ParseMetadata(appBundleItem);
 
            // Validation of any metadata specific to iOS stuff goes here
            if (!appBundleItem.TryGetMetadata(MetadataNames.Target, out string target))
            {
                Log.LogError($"'{MetadataNames.Target}' metadata must be specified - " +
                    "expecting list of target device/simulator platforms to execute tests on (e.g. ios-simulator-64)");
                return null;
            }
 
            target = target.ToLowerInvariant();
 
            // Optional timeout for the how long it takes for the app to be installed, booted and tests start executing
            TimeSpan launchTimeout = target.Contains("device") ? s_defaultDeviceLaunchTimeout : s_defaultSimulatorLaunchTimeout;
            if (appBundleItem.TryGetMetadata(MetadataNames.LaunchTimeout, out string launchTimeoutProp))
            {
                if (!TimeSpan.TryParse(launchTimeoutProp, out launchTimeout) || launchTimeout.Ticks < 0)
                {
                    Log.LogError($"Invalid value \"{launchTimeoutProp}\" provided in <{MetadataNames.LaunchTimeout}>");
                    return null;
                }
            }
 
            bool includesTestRunner = true;
            if (appBundleItem.TryGetMetadata(MetadataNames.IncludesTestRunner, out string includesTestRunnerProp))
            {
                if (includesTestRunnerProp.ToLowerInvariant() == "false")
                {
                    includesTestRunner = false;
                }
            }
 
            if (includesTestRunner && expectedExitCode != 0 && customCommands != null)
            {
                Log.LogWarning($"The {MetadataName.ExpectedExitCode} property is ignored in the `apple test` scenario");
            }
 
            bool resetSimulator = false;
            if (appBundleItem.TryGetMetadata(MetadataNames.ResetSimulator, out string resetSimulatorRunnerProp))
            {
                if (resetSimulatorRunnerProp.ToLowerInvariant() == "true")
                {
                    resetSimulator = true;
                }
            }
 
            if (customCommands == null)
            {
                // When no user commands are specified, we add the default `apple test ...` command
                customCommands = GetDefaultCommand(includesTestRunner, resetSimulator);
            }
 
            string appName = isAlreadyArchived ? $"{fileSystem.GetFileNameWithoutExtension(appFolderPath)}.app" : fileSystem.GetFileName(appFolderPath);
            string helixCommand = GetHelixCommand(appName, target, workItemTimeout, testTimeout, launchTimeout, includesTestRunner, expectedExitCode, resetSimulator);
            string payloadArchivePath = await CreatePayloadArchive(
                zipArchiveManager,
                fileSystem,
                workItemName,
                isAlreadyArchived,
                isPosix: true,
                appFolderPath,
                customCommands,
                new[] { EntryPointScript, RunnerScript });
 
            provisioningProfileProvider.AddProfileToPayload(payloadArchivePath, target);
 
            return CreateTaskItem(workItemName, payloadArchivePath, helixCommand, workItemTimeout);
        }
 
        private bool ValidateAppBundlePath(
            IFileSystem fileSystem, 
            string appBundlePath, 
            bool isAlreadyArchived)
        {
            return isAlreadyArchived ? fileSystem.FileExists(appBundlePath) : fileSystem.DirectoryExists(appBundlePath);
        }
 
        private string GetDefaultCommand(bool includesTestRunner, bool resetSimulator) =>
            $"xharness apple {(includesTestRunner ? "test" : "run")} " +
            "--app \"$app\" " +
            "--output-directory \"$output_directory\" " +
            "--target \"$target\" " +
            "--timeout \"$timeout\" " +
            "--launch-timeout \"$launch_timeout\" " +
            "--xcode \"$xcode_path\" " +
            "-v " +
            (!includesTestRunner ? "--expected-exit-code $expected_exit_code " : string.Empty) +
            (resetSimulator ? $"--reset-simulator " : string.Empty) +
            (!string.IsNullOrEmpty(AppArguments) ? "-- " + AppArguments : string.Empty);
 
        private string GetHelixCommand(
            string appName,
            string target,
            TimeSpan workItemTimeout,
            TimeSpan testTimeout,
            TimeSpan launchTimeout,
            bool includesTestRunner,
            int expectedExitCode,
            bool resetSimulator)
            =>
            $"chmod +x {EntryPointScript} && ./{EntryPointScript} " +
            $"--app \"{appName}\" " +
            $"--target \"{target}\" " +
            $"--command-timeout {(int)workItemTimeout.TotalSeconds} " +
            $"--timeout \"{testTimeout}\" " +
            $"--launch-timeout \"{launchTimeout}\" " +
            (includesTestRunner ? "--includes-test-runner " : string.Empty) +
            (resetSimulator ? "--reset-simulator" : string.Empty) +
            $"--expected-exit-code \"{expectedExitCode}\" " +
            (!string.IsNullOrEmpty(XcodeVersion) ? $" --xcode-version \"{XcodeVersion}\"" : string.Empty) +
            (!string.IsNullOrEmpty(AppArguments) ? $" --app-arguments \"{AppArguments}\"" : string.Empty);
    }
}