diff --git a/Arcade.sln b/Arcade.sln index 9b303e6671c..1036c6ac99a 100644 --- a/Arcade.sln +++ b/Arcade.sln @@ -897,18 +897,20 @@ Global {3CCA947D-B466-4481-BC78-43CE98A8A3A7} = {80888CDA-37DC-4061-AF5F-7237BF16A320} {86D901E6-C054-445B-92EC-E267EBB16278} = {80888CDA-37DC-4061-AF5F-7237BF16A320} {4626A7D1-7AC0-4E21-9FED-083EF2A8194C} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {036A1269-EA1B-457A-8447-D90AEF80BAF9} = {C53DD924-C212-49EA-9BC4-1827421361EF} {41F3EF12-6062-44CE-A1DB-1DCA76122AF8} = {C53DD924-C212-49EA-9BC4-1827421361EF} {6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71} = {C53DD924-C212-49EA-9BC4-1827421361EF} - {3376C769-211F-4537-A156-5F841FF7840B} = {6BE49638-F842-4329-BE8A-30C0F30CCAA5} - {03390E61-9DC1-4893-93A4-193D76C16034} = {6BE49638-F842-4329-BE8A-30C0F30CCAA5} + {3376C769-211F-4537-A156-5F841FF7840B} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {03390E61-9DC1-4893-93A4-193D76C16034} = {C53DD924-C212-49EA-9BC4-1827421361EF} {D6AC20A4-1719-49FE-B112-B2AB564496F8} = {C53DD924-C212-49EA-9BC4-1827421361EF} - {61041759-64FE-425F-8984-BA876428A595} = {E41E23C4-5CB0-4C61-9E05-EEFFEC4B356D} + {61041759-64FE-425F-8984-BA876428A595} = {C53DD924-C212-49EA-9BC4-1827421361EF} {52E92416-5E5F-4A62-A837-9D8554DD2805} = {6F517597-E9E2-43B2-B7E2-757132EA525C} {6F517597-E9E2-43B2-B7E2-757132EA525C} = {E41E23C4-5CB0-4C61-9E05-EEFFEC4B356D} {1CC55B23-6212-4120-BF52-8DED9CFF9FBC} = {6F517597-E9E2-43B2-B7E2-757132EA525C} {CE5278A3-2442-4309-A543-5BA5C1C76A2A} = {C53DD924-C212-49EA-9BC4-1827421361EF} {E941EDE6-3FFB-4776-A4CE-750755D57817} = {C53DD924-C212-49EA-9BC4-1827421361EF} {6CA09DC9-E654-4906-A977-1279F6EDC109} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {8BBF14AC-48F0-4282-910E-48E816021660} = {C53DD924-C212-49EA-9BC4-1827421361EF} {B5E9D9D8-59E0-49F8-9C3C-75138A2D452C} = {C53DD924-C212-49EA-9BC4-1827421361EF} {0B5D3C20-EB58-4A82-A3AA-2E626A17B35D} = {C53DD924-C212-49EA-9BC4-1827421361EF} EndGlobalSection diff --git a/src/Common/Microsoft.Arcade.Common/FileSystem.cs b/src/Common/Microsoft.Arcade.Common/FileSystem.cs index 51c5949d93c..d5a12c3d3eb 100644 --- a/src/Common/Microsoft.Arcade.Common/FileSystem.cs +++ b/src/Common/Microsoft.Arcade.Common/FileSystem.cs @@ -3,24 +3,28 @@ using System.IO; +#nullable enable namespace Microsoft.Arcade.Common { public class FileSystem : IFileSystem { - public void CreateDirectory(string path) - { - Directory.CreateDirectory(path); - } + public void CreateDirectory(string path) => Directory.CreateDirectory(path); - public bool DirectoryExists(string path) - { - return Directory.Exists(path); - } + public bool DirectoryExists(string path) => Directory.Exists(path); - public bool FileExists(string path) - { - return File.Exists(path); - } + public bool FileExists(string path) => File.Exists(path); + + public void DeleteFile(string path) => File.Delete(path); + + public string? GetDirectoryName(string? path) => Path.GetDirectoryName(path); + + public string? GetFileName(string? path) => Path.GetFileName(path); + + public string? GetFileNameWithoutExtension(string? path) => Path.GetFileNameWithoutExtension(path); + + public string? GetExtension(string? path) => Path.GetExtension(path); + + public string PathCombine(string path1, string path2) => Path.Combine(path1, path2); public void WriteToFile(string path, string content) { @@ -28,5 +32,9 @@ public void WriteToFile(string path, string content) Directory.CreateDirectory(dirPath); File.WriteAllText(path, content); } + + public void FileCopy(string sourceFileName, string destFileName) => File.Copy(sourceFileName, destFileName); + + public Stream GetFileStream(string path, FileMode mode, FileAccess access) => new FileStream(path, mode, access); } } diff --git a/src/Common/Microsoft.Arcade.Common/IFileSystem.cs b/src/Common/Microsoft.Arcade.Common/IFileSystem.cs index ea6d6705892..538f7d71030 100644 --- a/src/Common/Microsoft.Arcade.Common/IFileSystem.cs +++ b/src/Common/Microsoft.Arcade.Common/IFileSystem.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable +using System.IO; + namespace Microsoft.Arcade.Common { public interface IFileSystem @@ -12,5 +15,21 @@ public interface IFileSystem bool DirectoryExists(string path); void CreateDirectory(string path); + + string? GetFileName(string? path); + + string? GetDirectoryName(string? path); + + string? GetFileNameWithoutExtension(string? path); + + string? GetExtension(string? path); + + string PathCombine(string path1, string path2); + + void DeleteFile(string path); + + void FileCopy(string sourceFileName, string destFileName); + + Stream GetFileStream(string path, FileMode mode, FileAccess access); } } diff --git a/src/Common/Microsoft.Arcade.Common/IZipArchiveManager.cs b/src/Common/Microsoft.Arcade.Common/IZipArchiveManager.cs new file mode 100644 index 00000000000..910f5530a11 --- /dev/null +++ b/src/Common/Microsoft.Arcade.Common/IZipArchiveManager.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; + +namespace Microsoft.Arcade.Common +{ + public interface IZipArchiveManager + { + /// + /// Loads an embedded resource and adds it to a target archive. + /// + /// Type from the assembly where the resource is embedded (usually the caller) + /// Path to the archive where the file will be added + /// Name of the embedded resource + /// New name of the file in the archive + Task AddResourceFileToArchive(string archivePath, string resourceName, string targetFileName = null); + + /// + /// Compresses a directory into an archive on a given path. + /// + /// The directory to archive + /// Path where to create the archive + /// When true, includes top-level directory in the archive + void ArchiveDirectory(string directoryPath, string archivePath, bool includeBaseDirectory); + + /// + /// Creates a new archive containing given file. + /// + /// File to archive + /// Path where to create the archive + void ArchiveFile(string filePath, string archivePath); + } +} diff --git a/src/Common/Microsoft.Arcade.Common/Microsoft.Arcade.Common.csproj b/src/Common/Microsoft.Arcade.Common/Microsoft.Arcade.Common.csproj index 1f20bc3f49b..324725f5426 100644 --- a/src/Common/Microsoft.Arcade.Common/Microsoft.Arcade.Common.csproj +++ b/src/Common/Microsoft.Arcade.Common/Microsoft.Arcade.Common.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/src/Common/Microsoft.Arcade.Common/ZipArchiveManager.cs b/src/Common/Microsoft.Arcade.Common/ZipArchiveManager.cs new file mode 100644 index 00000000000..a805eaf98a0 --- /dev/null +++ b/src/Common/Microsoft.Arcade.Common/ZipArchiveManager.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Arcade.Common +{ + public class ZipArchiveManager : IZipArchiveManager + { + public async Task AddResourceFileToArchive(string archivePath, string resourceName, string targetFileName = null) + { + using Stream fileStream = GetResourceFileContent(resourceName); + await AddContentToArchive(archivePath, targetFileName ?? resourceName, fileStream); + } + + public void ArchiveDirectory(string directoryPath, string archivePath, bool includeBaseDirectory) + { + ZipFile.CreateFromDirectory(directoryPath, archivePath, CompressionLevel.Fastest, includeBaseDirectory); + } + + public void ArchiveFile(string filePath, string archivePath) + { + using (FileStream fs = File.OpenWrite(archivePath)) + using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, false)) + { + zip.CreateEntryFromFile(filePath, Path.GetFileName(filePath)); + } + } + + private async Task AddContentToArchive(string archivePath, string targetFilename, Stream content) + { + using FileStream archiveStream = new FileStream(archivePath, FileMode.Open); + using ZipArchive archive = new ZipArchive(archiveStream, ZipArchiveMode.Update); + ZipArchiveEntry entry = archive.CreateEntry(targetFilename); + using Stream targetStream = entry.Open(); + await content.CopyToAsync(targetStream); + } + + private static Stream GetResourceFileContent(string resourceFileName) + { + Assembly assembly = typeof(TAssembly).Assembly; + return assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{resourceFileName}"); + } + } +} diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/TestDoubles/FakeHttpClient.cs b/src/Common/Microsoft.Arcade.Test.Common/FakeHttpClient.cs similarity index 83% rename from src/Microsoft.DotNet.Build.Tasks.Feed.Tests/TestDoubles/FakeHttpClient.cs rename to src/Common/Microsoft.Arcade.Test.Common/FakeHttpClient.cs index a47e3c4ee0c..20fa104296f 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/TestDoubles/FakeHttpClient.cs +++ b/src/Common/Microsoft.Arcade.Test.Common/FakeHttpClient.cs @@ -7,14 +7,11 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.DotNet.Build.Tasks.Feed.Tests.TestDoubles +namespace Microsoft.DotNet.Arcade.Test.Common { public static class FakeHttpClient { - public static HttpClient WithResponse(HttpResponseMessage response) - => WithResponses(new[] { response }); - - public static HttpClient WithResponses(IEnumerable responses) + public static HttpClient WithResponses(params HttpResponseMessage[] responses) => new HttpClient( new FakeHttpMessageHandler(responses)); @@ -44,5 +41,4 @@ protected override Task SendAsync(HttpRequestMessage reques } } } - } diff --git a/src/Common/Microsoft.Arcade.Test.Common/Microsoft.Arcade.Test.Common.csproj b/src/Common/Microsoft.Arcade.Test.Common/Microsoft.Arcade.Test.Common.csproj index 6b2a68baf03..bb3c5eeb152 100644 --- a/src/Common/Microsoft.Arcade.Test.Common/Microsoft.Arcade.Test.Common.csproj +++ b/src/Common/Microsoft.Arcade.Test.Common/Microsoft.Arcade.Test.Common.csproj @@ -10,4 +10,12 @@ + + + + + + + + diff --git a/src/Common/Microsoft.Arcade.Test.Common/MockFileSystem.cs b/src/Common/Microsoft.Arcade.Test.Common/MockFileSystem.cs new file mode 100644 index 00000000000..d1a17a32858 --- /dev/null +++ b/src/Common/Microsoft.Arcade.Test.Common/MockFileSystem.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using Microsoft.Arcade.Common; + +#nullable enable +namespace Microsoft.Arcade.Test.Common +{ + public class MockFileSystem : IFileSystem + { + #region File system state + + public HashSet Directories { get; } + + public Dictionary Files { get; } + + public List RemovedFiles { get; } = new(); + + #endregion + + public MockFileSystem( + Dictionary? files = null, + IEnumerable? directories = null) + { + Directories = new(directories ?? new string[0]); + Files = files ?? new(); + } + + #region IFileSystem implementation + + public void CreateDirectory(string path) => Directories.Add(path); + + public bool DirectoryExists(string path) => Directories.Contains(path); + + public bool FileExists(string path) => Files.ContainsKey(path); + + public void DeleteFile(string path) + { + Files.Remove(path); + RemovedFiles.Add(path); + } + + public string? GetDirectoryName(string? path) => Path.GetDirectoryName(path); + + public string? GetFileName(string? path) => Path.GetFileName(path); + + public string? GetFileNameWithoutExtension(string? path) => Path.GetFileNameWithoutExtension(path); + + public string? GetExtension(string? path) => Path.GetExtension(path); + + public string PathCombine(string path1, string path2) => path1 + "/" + path2; + + public void WriteToFile(string path, string content) => Files[path] = content; + + public void FileCopy(string sourceFileName, string destFileName) => Files[destFileName] = Files[sourceFileName]; + + public Stream GetFileStream(string path, FileMode mode, FileAccess access) + => FileExists(path) ? new MemoryStream() : new MockFileStream(this, path); + + #endregion + + /// + /// Allows to write to a stream that will end up in the MockFileSystem. + /// + private class MockFileStream : MemoryStream + { + private readonly MockFileSystem _fileSystem; + private readonly string _path; + private bool _disposed = false; + + public MockFileStream(MockFileSystem fileSystem, string path) + : base(fileSystem.FileExists(path) ? System.Text.Encoding.UTF8.GetBytes(fileSystem.Files[path]) : new byte[2048]) + { + _fileSystem = fileSystem; + _path = path; + } + + protected override void Dispose(bool disposing) + { + // flush file to our system + if (!_disposed) + { + _disposed = true; + using var sr = new StreamReader(this); + Seek(0, SeekOrigin.Begin); + _fileSystem.WriteToFile(_path, sr.ReadToEnd().Replace("\0", "")); + } + } + } + } +} diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/GeneralTests.cs b/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/GeneralTests.cs index 2b74d7a3835..e9c8d7bbd0b 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/GeneralTests.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed.Tests/GeneralTests.cs @@ -10,6 +10,7 @@ using Xunit; using static Microsoft.DotNet.Build.Tasks.Feed.GeneralUtils; using FluentAssertions; +using Microsoft.DotNet.Arcade.Test.Common; namespace Microsoft.DotNet.Build.Tasks.Feed.Tests { @@ -55,7 +56,7 @@ public async Task IsFeedPublicShouldCorrectlyInterpretFeedResponseStatusCode( HttpStatusCode feedResponseStatusCode, bool? expectedResult) { - using var httpClient = FakeHttpClient.WithResponse( + using var httpClient = FakeHttpClient.WithResponses( new HttpResponseMessage(feedResponseStatusCode)); var retryHandler = new MockRetryHandler(); @@ -117,7 +118,7 @@ public async Task CompareLocalPackageToFeedPackageShouldCorrectlyInterpretFeedRe response.Content = new ByteArrayContent(content); }; - var httpClient = FakeHttpClient.WithResponse(response); + var httpClient = FakeHttpClient.WithResponses(response); var result = await GeneralUtils.CompareLocalPackageToFeedPackage( localPackagePath, diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs new file mode 100644 index 00000000000..af852c7a545 --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Arcade.Common; +using Microsoft.Arcade.Test.Common; +using Microsoft.Build.Framework; +using Microsoft.DotNet.Internal.DependencyInjection.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +#nullable enable +namespace Microsoft.DotNet.Helix.Sdk.Tests +{ + public class CreateXHarnessAndroidWorkItemsTests + { + private readonly MockFileSystem _fileSystem; + private readonly Mock _zipArchiveManager; + private readonly CreateXHarnessAndroidWorkItems _task; + + public CreateXHarnessAndroidWorkItemsTests() + { + _fileSystem = new MockFileSystem(); + _zipArchiveManager = new(); + _zipArchiveManager.SetReturnsDefault(Task.CompletedTask); + _zipArchiveManager + .Setup(x => x.ArchiveFile(It.IsAny(), It.IsAny())) + .Callback((folder, zipPath) => + { + _fileSystem.Files.Add(zipPath, "zip of " + folder); + }); + + _task = new CreateXHarnessAndroidWorkItems() + { + BuildEngine = new MockBuildEngine(), + }; + } + + [Fact] + public void MissingApkNamePropertyIsCaught() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.Apks = new[] + { + CreateApk("/apks/System.Foo.app", null!) + }; + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeFalse(); + + // Verify + _task.WorkItems.Length.Should().Be(0); + } + + [Fact] + public void AndroidXHarnessWorkItemIsCreated() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.Apks = new[] + { + CreateApk("/apks/System.Foo.apk", "System.Foo", "00:15:42", "00:08:55") + }; + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeTrue(); + + // Verify + _task.WorkItems.Length.Should().Be(1); + + var workItem = _task.WorkItems.First(); + workItem.GetMetadata("Identity").Should().Be("System.Foo"); + workItem.GetMetadata("Timeout").Should().Be("00:15:42"); + + var payloadArchive = workItem.GetMetadata("PayloadArchive"); + payloadArchive.Should().NotBeNullOrEmpty(); + _fileSystem.FileExists(payloadArchive).Should().BeTrue(); + + var command = workItem.GetMetadata("Command"); + command.Should().Contain("--timeout \"00:08:55\""); + + _zipArchiveManager + .Verify(x => x.AddResourceFileToArchive(payloadArchive, It.Is(s => s.Contains("xharness-helix-job.android.sh")), "xharness-helix-job.android.sh"), Times.AtLeastOnce); + _zipArchiveManager + .Verify(x => x.AddResourceFileToArchive(payloadArchive, It.Is(s => s.Contains("xharness-helix-job.android.bat")), "xharness-helix-job.android.bat"), Times.AtLeastOnce); + } + + [Fact] + public void ArchivePayloadIsOverwritten() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.Apks = new[] + { + CreateApk("apks/System.Foo.apk", "System.Foo"), + CreateApk("apks/System.Bar.apk", "System.Bar"), + }; + + _fileSystem.Files.Add("apks/xharness-apk-payload-system.foo.zip", "archive"); + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeTrue(); + + // Verify + _task.WorkItems.Length.Should().Be(2); + + var workItem = _task.WorkItems.Last(); + workItem.GetMetadata("Identity").Should().Be("System.Bar"); + + workItem = _task.WorkItems.First(); + workItem.GetMetadata("Identity").Should().Be("System.Foo"); + + var payloadArchive = workItem.GetMetadata("PayloadArchive"); + payloadArchive.Should().NotBeNullOrEmpty(); + _fileSystem.FileExists(payloadArchive).Should().BeTrue(); + _fileSystem.RemovedFiles.Should().Contain(payloadArchive); + } + + [Fact] + public void AreDependenciesRegistered() + { + var task = new CreateXHarnessAndroidWorkItems(); + + var collection = new ServiceCollection(); + task.ConfigureServices(collection); + var provider = collection.BuildServiceProvider(); + + DependencyInjectionValidation.IsDependencyResolutionCoherent( + s => task.ConfigureServices(s), + out string message, + additionalSingletonTypes: task.GetExecuteParameterTypes() + ) + .Should() + .BeTrue(message); + } + + private ITaskItem CreateApk( + string path, + string apkName, + string? workItemTimeout = null, + string? testTimeout = null, + int expectedExitCode = 0) + { + var mockBundle = new Mock(); + mockBundle.SetupGet(x => x.ItemSpec).Returns(path); + mockBundle.Setup(x => x.GetMetadata("AndroidPackageName")).Returns(apkName); + + if (workItemTimeout != null) + { + mockBundle.Setup(x => x.GetMetadata("WorkItemTimeout")).Returns(workItemTimeout); + } + + if (testTimeout != null) + { + mockBundle.Setup(x => x.GetMetadata("TestTimeout")).Returns(testTimeout); + } + + if (expectedExitCode != 0) + { + mockBundle.Setup(x => x.GetMetadata("ExpectedExitCode")).Returns(expectedExitCode.ToString()); + } + + _fileSystem.WriteToFile(path, "apk"); + + return mockBundle.Object; + } + + private IServiceCollection CreateMockServiceCollection() + { + var collection = new ServiceCollection(); + collection.AddSingleton(_fileSystem); + collection.AddSingleton(_zipArchiveManager.Object); + return collection; + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs new file mode 100644 index 00000000000..2cfea1576b8 --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Arcade.Common; +using Microsoft.Arcade.Test.Common; +using Microsoft.Build.Framework; +using Microsoft.DotNet.Internal.DependencyInjection.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +#nullable enable +namespace Microsoft.DotNet.Helix.Sdk.Tests +{ + public class CreateXHarnessAppleWorkItemsTests + { + private readonly MockFileSystem _fileSystem; + private readonly Mock _profileProvider; + private readonly Mock _zipArchiveManager; + private readonly CreateXHarnessAppleWorkItems _task; + + public CreateXHarnessAppleWorkItemsTests() + { + _fileSystem = new MockFileSystem(); + _profileProvider = new(); + _zipArchiveManager = new(); + _zipArchiveManager.SetReturnsDefault(Task.CompletedTask); + _zipArchiveManager + .Setup(x => x.ArchiveDirectory(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((folder, zipPath, _) => + { + _fileSystem.Files.Add(zipPath, "zip of " + folder); + }); + + _task = new CreateXHarnessAppleWorkItems() + { + BuildEngine = new MockBuildEngine(), + }; + } + + [Fact] + public void MissingTargetsPropertyIsCaught() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.AppBundles = new[] + { + CreateAppBundle("/apps/System.Foo.app", null!) + }; + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeFalse(); + + // Verify + _task.WorkItems.Length.Should().Be(0); + } + + [Fact] + public void AppleXHarnessWorkItemIsCreated() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.AppBundles = new[] + { + CreateAppBundle("/apps/System.Foo.app", "ios-device_13.5", "00:15:42", "00:08:55", "00:02:33") + }; + _task.TmpDir = "/tmp"; + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeTrue(); + + // Verify + _task.WorkItems.Length.Should().Be(1); + + var workItem = _task.WorkItems.First(); + workItem.GetMetadata("Identity").Should().Be("System.Foo"); + workItem.GetMetadata("Timeout").Should().Be("00:15:42"); + + var payloadArchive = workItem.GetMetadata("PayloadArchive"); + payloadArchive.Should().NotBeNullOrEmpty(); + _fileSystem.FileExists(payloadArchive).Should().BeTrue(); + + var command = workItem.GetMetadata("Command"); + command.Should().Contain("--command test"); + command.Should().Contain("--timeout \"00:08:55\""); + command.Should().Contain("--launch-timeout \"00:02:33\""); + + _profileProvider + .Verify(x => x.AddProfilesToBundles(It.Is(bundles => bundles.Any(b => b.ItemSpec == "/apps/System.Foo.app"))), Times.AtLeastOnce); + _zipArchiveManager + .Verify(x => x.AddResourceFileToArchive(payloadArchive, It.Is(s => s.Contains("xharness-helix-job.apple.sh")), "xharness-helix-job.apple.sh"), Times.AtLeastOnce); + _zipArchiveManager + .Verify(x => x.AddResourceFileToArchive(payloadArchive, It.Is(s => s.Contains("xharness-runner.apple.sh")), "xharness-runner.apple.sh"), Times.AtLeastOnce); + } + + [Fact] + public void ArchivePayloadIsOverwritten() + { + var collection = CreateMockServiceCollection(); + _task.ConfigureServices(collection); + _task.AppBundles = new[] + { + CreateAppBundle("apps/System.Foo.app", "ios-simulator-64_13.5"), + CreateAppBundle("apps/System.Bar.app", "ios-simulator-64_13.5"), + }; + + _fileSystem.Files.Add("apps/xharness-app-payload-system.foo.app.zip", "archive"); + + // Act + using var provider = collection.BuildServiceProvider(); + _task.InvokeExecute(provider).Should().BeTrue(); + + // Verify + _task.WorkItems.Length.Should().Be(2); + + var workItem = _task.WorkItems.Last(); + workItem.GetMetadata("Identity").Should().Be("System.Bar"); + + workItem = _task.WorkItems.First(); + workItem.GetMetadata("Identity").Should().Be("System.Foo"); + + var payloadArchive = workItem.GetMetadata("PayloadArchive"); + payloadArchive.Should().NotBeNullOrEmpty(); + _fileSystem.FileExists(payloadArchive).Should().BeTrue(); + _fileSystem.RemovedFiles.Should().Contain(payloadArchive); + } + + [Fact] + public void AreDependenciesRegistered() + { + var task = new CreateXHarnessAppleWorkItems(); + + var collection = new ServiceCollection(); + task.ConfigureServices(collection); + var provider = collection.BuildServiceProvider(); + + DependencyInjectionValidation.IsDependencyResolutionCoherent( + s => task.ConfigureServices(s), + out string message, + additionalSingletonTypes: task.GetExecuteParameterTypes() + ) + .Should() + .BeTrue(message); + } + + private ITaskItem CreateAppBundle( + string path, + string targets, + string? workItemTimeout = null, + string? testTimeout = null, + string? launchTimeout = null, + int expectedExitCode = 0, + bool includesTestRunner = true) + { + var mockBundle = new Mock(); + mockBundle.SetupGet(x => x.ItemSpec).Returns(path); + mockBundle.Setup(x => x.GetMetadata(CreateXHarnessAppleWorkItems.TargetPropName)).Returns(targets); + mockBundle.Setup(x => x.GetMetadata("IncludesTestRunner")).Returns(includesTestRunner.ToString()); + + if (workItemTimeout != null) + { + mockBundle.Setup(x => x.GetMetadata("WorkItemTimeout")).Returns(workItemTimeout); + } + + if (testTimeout != null) + { + mockBundle.Setup(x => x.GetMetadata("TestTimeout")).Returns(testTimeout); + } + + if (launchTimeout != null) + { + mockBundle.Setup(x => x.GetMetadata("LaunchTimeout")).Returns(launchTimeout); + } + + if (expectedExitCode != 0) + { + mockBundle.Setup(x => x.GetMetadata("ExpectedExitCode")).Returns(expectedExitCode.ToString()); + } + + _fileSystem.CreateDirectory(path); + + return mockBundle.Object; + } + + private IServiceCollection CreateMockServiceCollection() + { + var collection = new ServiceCollection(); + collection.AddSingleton(_fileSystem); + collection.AddSingleton(_profileProvider.Object); + collection.AddSingleton(_zipArchiveManager.Object); + return collection; + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj index c5b485723a3..5de33cd0a4f 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj @@ -6,12 +6,18 @@ + + + + + + diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/ProvisioningProfileProviderTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/ProvisioningProfileProviderTests.cs new file mode 100644 index 00000000000..f6794c98dba --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/ProvisioningProfileProviderTests.cs @@ -0,0 +1,140 @@ +// 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.Net; +using System.Net.Http; +using FluentAssertions; +using Microsoft.Arcade.Common; +using Microsoft.Arcade.Test.Common; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.DotNet.Arcade.Test.Common; +using Moq; +using Xunit; + +#nullable enable +namespace Microsoft.DotNet.Helix.Sdk.Tests +{ + public class ProvisioningProfileProviderTests + { + private readonly MockFileSystem _fileSystem; + private readonly Mock _helpersMock; + private readonly ProvisioningProfileProvider _profileProvider; + private int _downloadCount = 0; + + public ProvisioningProfileProviderTests() + { + _helpersMock = new Mock(); + _helpersMock + .Setup(x => x.DirectoryMutexExec(It.IsAny>(), It.IsAny())) + .Callback, string>((function, path) => { + ++_downloadCount; + function().GetAwaiter().GetResult(); + }); + + _fileSystem = new MockFileSystem(); + + var response1 = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("iOS content"), + }; + + var response2 = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("tvOS content"), + }; + + var httpClient = FakeHttpClient.WithResponses(response1, response2); + + _profileProvider = new ProvisioningProfileProvider( + new TaskLoggingHelper(new MockBuildEngine(), nameof(ProvisioningProfileProviderTests)), + _helpersMock.Object, + _fileSystem, + httpClient, + "https://netcorenativeassets.azure.com/profiles/{PLATFORM}.mobileprovision", + "/tmp"); + } + + [Fact] + public void NonDeviceTargetsAreIgnored() + { + _profileProvider.AddProfilesToBundles(new[] + { + CreateAppBundle("/apps/System.Foo.app", "ios-simulator-64_13.5"), + CreateAppBundle("/apps/System.Bar.app", "tvos-simulator-64"), + }); + + _downloadCount.Should().Be(0); + _fileSystem.Files.Should().BeEmpty(); + } + + [Fact] + public void MultipleiOSDeviceTargetsGetTheSameProfile() + { + _profileProvider.AddProfilesToBundles(new[] + { + CreateAppBundle("/apps/System.Device1.app", "ios-device"), + CreateAppBundle("/apps/System.Simulator.app", "tvos-simulator-64"), + CreateAppBundle("/apps/System.Device2.app", "ios-device"), + CreateAppBundle("/apps/System.Foo.app", "ios-simulator-64_13.5"), + }); + + _downloadCount.Should().Be(1); + + _fileSystem.Files.Keys.Should().BeEquivalentTo( + "/tmp/iOS.mobileprovision", + "/apps/System.Device1.app/embedded.mobileprovision", + "/apps/System.Device2.app/embedded.mobileprovision"); + + _fileSystem.Files["/apps/System.Device1.app/embedded.mobileprovision"].Should().Be("iOS content"); + _fileSystem.Files["/apps/System.Device2.app/embedded.mobileprovision"].Should().Be("iOS content"); + } + + [Fact] + public void MultiplePlatformsGetTheirProfile() + { + _profileProvider.AddProfilesToBundles(new[] + { + CreateAppBundle("/apps/System.Device1.iOS.app", "ios-device"), + CreateAppBundle("/apps/System.Simulator.app", "tvos-simulator-64"), + CreateAppBundle("/apps/System.Device2.iOS.app", "ios-device"), + CreateAppBundle("/apps/System.Device3.tvOS.app", "tvos-device"), + }); + + _downloadCount.Should().Be(2); + + _fileSystem.Files.Keys.Should().BeEquivalentTo( + "/tmp/iOS.mobileprovision", + "/tmp/tvOS.mobileprovision", + "/apps/System.Device1.iOS.app/embedded.mobileprovision", + "/apps/System.Device2.iOS.app/embedded.mobileprovision", + "/apps/System.Device3.tvOS.app/embedded.mobileprovision"); + + _fileSystem.Files["/apps/System.Device1.iOS.app/embedded.mobileprovision"].Should().Be("iOS content"); + _fileSystem.Files["/apps/System.Device2.iOS.app/embedded.mobileprovision"].Should().Be("iOS content"); + _fileSystem.Files["/apps/System.Device3.tvOS.app/embedded.mobileprovision"].Should().Be("tvOS content"); + } + + [Fact] + public void BundlesContainingProfileAreIgnored() + { + _fileSystem.WriteToFile("/apps/System.Device1.app/embedded.mobileprovision", "iOS content"); + _profileProvider.AddProfilesToBundles(new[] + { + CreateAppBundle("/apps/System.Device1.app", "ios-device"), + CreateAppBundle("/apps/System.Simulator.app", "tvos-simulator-64"), + }); + + _downloadCount.Should().Be(0); + } + + private static ITaskItem CreateAppBundle(string path, string targets) + { + var mockBundle = new Mock(); + mockBundle.SetupGet(x => x.ItemSpec).Returns(path); + mockBundle.Setup(x => x.GetMetadata(CreateXHarnessAppleWorkItems.TargetPropName)).Returns(targets); + return mockBundle.Object; + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs index 135d7ea7b3b..1af2dd4286b 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Compression; 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 { @@ -13,33 +15,46 @@ namespace Microsoft.DotNet.Helix.Sdk /// public class CreateXHarnessAndroidWorkItems : XHarnessTaskBase { + private const string ArgumentsPropName = "Arguments"; + private const string AndroidInstrumentationNamePropName = "AndroidInstrumentationName"; + private const string DeviceOutputPathPropName = "DeviceOutputPath"; + private const string AndroidPackageNamePropName = "AndroidPackageName"; + private const string PosixAndroidWrapperScript = "xharness-helix-job.android.sh"; private const string NonPosixAndroidWrapperScript = "xharness-helix-job.android.bat"; + /// + /// 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 + /// + [Required] + public bool IsPosixShell { get; set; } + /// /// An array of one or more paths to application packages (.apk for Android) /// that will be used to create Helix work items. /// public ITaskItem[] Apks { get; set; } + public override void ConfigureServices(IServiceCollection collection) + { + collection.TryAddTransient(); + collection.TryAddTransient(); + collection.TryAddSingleton(Log); + } + /// /// 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 /// /// A boolean value indicating the success of HelixWorkItem creation - public override bool Execute() + public bool ExecuteTask(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem) { - ExecuteAsync().GetAwaiter().GetResult(); - return !Log.HasLoggedErrors; - } + var tasks = Apks.Select(apk => PrepareWorkItem(zipArchiveManager, fileSystem, apk)); - /// - /// Create work items for XHarness test execution - /// - /// - private async Task ExecuteAsync() - { - WorkItems = (await Task.WhenAll(Apks.Select(PrepareWorkItem))).Where(wi => wi != null).ToArray(); + WorkItems = Task.WhenAll(tasks).GetAwaiter().GetResult().Where(wi => wi != null).ToArray(); + + return !Log.HasLoggedErrors; } /// @@ -47,66 +62,66 @@ private async Task ExecuteAsync() /// /// Path to application package /// An ITaskItem instance representing the prepared HelixWorkItem. - private async Task PrepareWorkItem(ITaskItem appPackage) + private async Task PrepareWorkItem(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem, ITaskItem appPackage) { - // Forces this task to run asynchronously - await Task.Yield(); - string workItemName = Path.GetFileNameWithoutExtension(appPackage.ItemSpec); + string workItemName = fileSystem.GetFileNameWithoutExtension(appPackage.ItemSpec); var (testTimeout, workItemTimeout, expectedExitCode) = ParseMetadata(appPackage); string command = ValidateMetadataAndGetXHarnessAndroidCommand(appPackage, testTimeout, expectedExitCode); - if (!Path.GetExtension(appPackage.ItemSpec).Equals(".apk", StringComparison.OrdinalIgnoreCase)) + if (!fileSystem.GetExtension(appPackage.ItemSpec).Equals(".apk", StringComparison.OrdinalIgnoreCase)) { - Log.LogError($"Unsupported app package type: {Path.GetFileName(appPackage.ItemSpec)}"); + Log.LogError($"Unsupported app package type: {fileSystem.GetFileName(appPackage.ItemSpec)}"); return null; } Log.LogMessage($"Creating work item with properties Identity: {workItemName}, Payload: {appPackage.ItemSpec}, Command: {command}"); - string workItemZip = await CreateZipArchiveOfPackageAsync(appPackage.ItemSpec); + string workItemZip = await CreateZipArchiveOfPackageAsync(zipArchiveManager, fileSystem, appPackage.ItemSpec); return new Build.Utilities.TaskItem(workItemName, new Dictionary() { { "Identity", workItemName }, - { "PayloadArchive", workItemZip}, + { "PayloadArchive", workItemZip }, { "Command", command }, { "Timeout", workItemTimeout.ToString() }, }); } - private async Task CreateZipArchiveOfPackageAsync(string fileToZip) + private async Task CreateZipArchiveOfPackageAsync(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem, string fileToZip) { - string directoryOfPackage = Path.GetDirectoryName(fileToZip); - string fileName = $"xharness-apk-payload-{Path.GetFileNameWithoutExtension(fileToZip).ToLowerInvariant()}.zip"; - string outputZipAbsolutePath = Path.Combine(directoryOfPackage, fileName); - using (FileStream fs = File.OpenWrite(outputZipAbsolutePath)) - using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, false)) + string fileName = $"xharness-apk-payload-{fileSystem.GetFileNameWithoutExtension(fileToZip).ToLowerInvariant()}.zip"; + string outputZipPath = fileSystem.PathCombine(fileSystem.GetDirectoryName(fileToZip), fileName); + + if (fileSystem.FileExists(outputZipPath)) { - zip.CreateEntryFromFile(fileToZip, Path.GetFileName(fileToZip)); + Log.LogMessage($"Zip archive '{outputZipPath}' already exists, overwriting.."); + fileSystem.DeleteFile(outputZipPath); } + zipArchiveManager.ArchiveFile(fileToZip, outputZipPath); + // WorkItem payloads of APKs can be reused if sent to multiple queues at once, // so we'll always include both scripts (very small) - await AddResourceFileToPayload(outputZipAbsolutePath, PosixAndroidWrapperScript); - await AddResourceFileToPayload(outputZipAbsolutePath, NonPosixAndroidWrapperScript); + await zipArchiveManager.AddResourceFileToArchive(outputZipPath, ScriptNamespace + PosixAndroidWrapperScript, PosixAndroidWrapperScript); + await zipArchiveManager.AddResourceFileToArchive(outputZipPath, ScriptNamespace + NonPosixAndroidWrapperScript, NonPosixAndroidWrapperScript); - return outputZipAbsolutePath; + return outputZipPath; } private string ValidateMetadataAndGetXHarnessAndroidCommand(ITaskItem appPackage, TimeSpan xHarnessTimeout, int expectedExitCode) { // Validation of any metadata specific to Android stuff goes here - if (!appPackage.GetRequiredMetadata(Log, "AndroidPackageName", out string androidPackageName)) + if (!appPackage.GetRequiredMetadata(Log, AndroidPackageNamePropName, out string androidPackageName)) { - Log.LogError("AndroidPackageName metadata must be specified; this may match, but can vary from file name"); + Log.LogError($"{AndroidPackageNamePropName} metadata must be specified; this may match, but can vary from file name"); return null; } - appPackage.TryGetMetadata("Arguments", out string arguments); - appPackage.TryGetMetadata("AndroidInstrumentationName", out string androidInstrumentationName); - appPackage.TryGetMetadata("DeviceOutputPath", out string deviceOutputPath); + appPackage.TryGetMetadata(ArgumentsPropName, out string arguments); + appPackage.TryGetMetadata(AndroidInstrumentationNamePropName, out string androidInstrumentationName); + appPackage.TryGetMetadata(DeviceOutputPathPropName, out string deviceOutputPath); string outputPathArg = string.IsNullOrEmpty(deviceOutputPath) ? string.Empty : $"--dev-out={deviceOutputPath} "; string instrumentationArg = string.IsNullOrEmpty(androidInstrumentationName) ? string.Empty : $"-i={androidInstrumentationName} "; diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs index f1bc08541fb..38bab231be6 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Compression; using System.Linq; -using System.Net; 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 { @@ -15,23 +15,27 @@ namespace Microsoft.DotNet.Helix.Sdk /// public class CreateXHarnessAppleWorkItems : XHarnessTaskBase { - private const string EntryPointScriptName = "xharness-helix-job.apple.sh"; - private const string RunnerScriptName = "xharness-runner.apple.sh"; + public const string TargetPropName = "Targets"; + public const string iOSTargetName = "ios-device"; + public const string tvOSTargetName = "tvos-device"; + private const string LaunchTimeoutPropName = "LaunchTimeout"; - private const string TargetPropName = "Targets"; private const string IncludesTestRunnerPropName = "IncludesTestRunner"; - private const int DefaultLaunchTimeoutInMinutes = 10; - private readonly IHelpers _helpers = new Arcade.Common.Helpers(); + private const string EntryPointScript = "xharness-helix-job.apple.sh"; + private const string RunnerScript = "xharness-runner.apple.sh"; + + private static readonly TimeSpan s_defaultLaunchTimeout = TimeSpan.FromMinutes(10); /// - /// An array of one or more paths to iOS app bundles (folders ending with ".app" usually) + /// 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. /// + [Required] public ITaskItem[] AppBundles { get; set; } /// - /// Xcode version to use in the [major].[minor] format, e.g. 11.4 + /// Xcode version to use, e.g. 11.4 or 12.5_beta3. /// public string XcodeVersion { get; set; } @@ -47,56 +51,45 @@ public class CreateXHarnessAppleWorkItems : XHarnessTaskBase /// public string TmpDir { get; set; } - private enum TargetPlatform + public override void ConfigureServices(IServiceCollection collection) { - iOS, - tvOS, + collection.TryAddProvisioningProfileProvider(ProvisioningProfileUrl, TmpDir); + collection.TryAddTransient(); + collection.TryAddTransient(); + collection.TryAddSingleton(Log); } - private string GetProvisioningProfileFileName(TargetPlatform platform) => Path.GetFileName(GetProvisioningProfileUrl(platform)); - - private string GetProvisioningProfileUrl(TargetPlatform platform) => ProvisioningProfileUrl.Replace("{PLATFORM}", platform.ToString()); - /// /// 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 /// /// A boolean value indicating the success of HelixWorkItem creation - public override bool Execute() + public bool ExecuteTask( + IProvisioningProfileProvider provisioningProfileProvider, + IZipArchiveManager zipArchiveManager, + IFileSystem fileSystem) { - if (!IsPosixShell) - { - Log.LogError("IsPosixShell was specified as false for an iOS work item; these can only run on MacOS devices currently."); - return false; - } + provisioningProfileProvider.AddProfilesToBundles(AppBundles); + var tasks = AppBundles.Select(bundle => PrepareWorkItem(zipArchiveManager, fileSystem, bundle)); - ExecuteAsync().GetAwaiter().GetResult(); + WorkItems = Task.WhenAll(tasks).GetAwaiter().GetResult().Where(wi => wi != null).ToArray(); + return !Log.HasLoggedErrors; } - /// - /// Create work items for XHarness test execution - /// - /// - private async Task ExecuteAsync() - { - DownloadProvisioningProfiles(); - WorkItems = (await Task.WhenAll(AppBundles.Select(PrepareWorkItem))).Where(wi => wi != null).ToArray(); - } - /// /// Prepares HelixWorkItem that can run on an iOS device using XHarness /// /// Path to application package /// An ITaskItem instance representing the prepared HelixWorkItem. - private async Task PrepareWorkItem(ITaskItem appBundleItem) + private async Task PrepareWorkItem( + IZipArchiveManager zipArchiveManager, + IFileSystem fileSystem, + ITaskItem appBundleItem) { - // Forces this task to run asynchronously - await Task.Yield(); - string appFolderPath = appBundleItem.ItemSpec.TrimEnd(Path.DirectorySeparatorChar); - string workItemName = Path.GetFileName(appFolderPath); + string workItemName = fileSystem.GetFileName(appFolderPath); if (workItemName.EndsWith(".app")) { workItemName = workItemName.Substring(0, workItemName.Length - 4); @@ -107,7 +100,7 @@ private async Task PrepareWorkItem(ITaskItem appBundleItem) // Validation of any metadata specific to iOS stuff goes here if (!appBundleItem.TryGetMetadata(TargetPropName, out string target)) { - Log.LogError("'Targets' metadata must be specified - " + + Log.LogError($"'{TargetPropName}' metadata must be specified - " + "expecting list of target device/simulator platforms to execute tests on (e.g. ios-simulator-64)"); return null; } @@ -115,7 +108,7 @@ private async Task PrepareWorkItem(ITaskItem appBundleItem) target = target.ToLowerInvariant(); // Optional timeout for the how long it takes for the app to be installed, booted and tests start executing - TimeSpan launchTimeout = TimeSpan.FromMinutes(DefaultLaunchTimeoutInMinutes); + TimeSpan launchTimeout = s_defaultLaunchTimeout; if (appBundleItem.TryGetMetadata(LaunchTimeoutPropName, out string launchTimeoutProp)) { if (!TimeSpan.TryParse(launchTimeoutProp, out launchTimeout) || launchTimeout.Ticks < 0) @@ -139,53 +132,9 @@ private async Task PrepareWorkItem(ITaskItem appBundleItem) Log.LogWarning("The ExpectedExitCode property is ignored in the `apple test` scenario"); } - bool isDeviceTarget = target.Contains("device"); - string provisioningProfileDest = Path.Combine(appFolderPath, "embedded.mobileprovision"); - - // Handle files needed for signing - if (isDeviceTarget) - { - if (string.IsNullOrEmpty(TmpDir)) - { - Log.LogError(nameof(TmpDir) + " parameter not set but required for real device targets!"); - return null; - } - - if (string.IsNullOrEmpty(ProvisioningProfileUrl) && !File.Exists(provisioningProfileDest)) - { - Log.LogError(nameof(ProvisioningProfileUrl) + " parameter not set but required for real device targets!"); - return null; - } - - if (!File.Exists(provisioningProfileDest)) - { - // StartsWith because suffix can be the target OS version - TargetPlatform? platform = null; - if (target.StartsWith("ios-device")) - { - platform = TargetPlatform.iOS; - } - else if (target.StartsWith("tvos-device")) - { - platform = TargetPlatform.tvOS; - } - - if (platform.HasValue) - { - string profilePath = Path.Combine(TmpDir, GetProvisioningProfileFileName(platform.Value)); - Log.LogMessage($"Adding provisioning profile `{profilePath}` into the app bundle at `{provisioningProfileDest}`"); - File.Copy(profilePath, provisioningProfileDest); - } - } - else - { - Log.LogMessage($"Bundle already contains a provisioning profile at `{provisioningProfileDest}`"); - } - } - - string appName = Path.GetFileName(appBundleItem.ItemSpec); + string appName = fileSystem.GetFileName(appBundleItem.ItemSpec); string command = GetHelixCommand(appName, target, testTimeout, launchTimeout, includesTestRunner, expectedExitCode); - string payloadArchivePath = await CreateZipArchiveOfFolder(appFolderPath); + string payloadArchivePath = await CreateZipArchiveOfFolder(zipArchiveManager, fileSystem, appFolderPath); Log.LogMessage($"Creating work item with properties Identity: {workItemName}, Payload: {appFolderPath}, Command: {command}"); @@ -199,7 +148,7 @@ private async Task PrepareWorkItem(ITaskItem appBundleItem) } private string GetHelixCommand(string appName, string targets, TimeSpan testTimeout, TimeSpan launchTimeout, bool includesTestRunner, int expectedExitCode) => - $"chmod +x {EntryPointScriptName} && ./{EntryPointScriptName} " + + $"chmod +x {EntryPointScript} && ./{EntryPointScript} " + $"--app \"$HELIX_WORKITEM_ROOT/{appName}\" " + "--output-directory \"$HELIX_WORKITEM_UPLOAD_ROOT\" " + $"--targets \"{targets}\" " + @@ -211,75 +160,31 @@ private string GetHelixCommand(string appName, string targets, TimeSpan testTime (!string.IsNullOrEmpty(XcodeVersion) ? $" --xcode-version \"{XcodeVersion}\"" : string.Empty) + (!string.IsNullOrEmpty(AppArguments) ? $" --app-arguments \"{AppArguments}\"" : string.Empty); - private async Task CreateZipArchiveOfFolder(string folderToZip) + private async Task CreateZipArchiveOfFolder(IZipArchiveManager zipArchiveManager, IFileSystem fileSystem, string folderToZip) { - if (!Directory.Exists(folderToZip)) + if (!fileSystem.DirectoryExists(folderToZip)) { Log.LogError($"Cannot find path containing app: '{folderToZip}'"); return string.Empty; } - string appFolderDirectory = Path.GetDirectoryName(folderToZip); - string fileName = $"xharness-ios-app-payload-{Path.GetFileName(folderToZip).ToLowerInvariant()}.zip"; - string outputZipPath = Path.Combine(appFolderDirectory, fileName); + string appFolderDirectory = fileSystem.GetDirectoryName(folderToZip); + string fileName = $"xharness-app-payload-{fileSystem.GetFileName(folderToZip).ToLowerInvariant()}.zip"; + string outputZipPath = fileSystem.PathCombine(appFolderDirectory, fileName); - if (File.Exists(outputZipPath)) + if (fileSystem.FileExists(outputZipPath)) { Log.LogMessage($"Zip archive '{outputZipPath}' already exists, overwriting.."); - File.Delete(outputZipPath); + fileSystem.DeleteFile(outputZipPath); } - ZipFile.CreateFromDirectory(folderToZip, outputZipPath, CompressionLevel.Fastest, includeBaseDirectory: true); + zipArchiveManager.ArchiveDirectory(folderToZip, outputZipPath, true); - Log.LogMessage($"Adding the Helix job payload scripts into the ziparchive"); - await AddResourceFileToPayload(outputZipPath, EntryPointScriptName); - await AddResourceFileToPayload(outputZipPath, RunnerScriptName); + Log.LogMessage($"Adding the XHarness job scripts into the payload archive"); + await zipArchiveManager.AddResourceFileToArchive(outputZipPath, ScriptNamespace + EntryPointScript, EntryPointScript); + await zipArchiveManager.AddResourceFileToArchive(outputZipPath, ScriptNamespace + RunnerScript, RunnerScript); return outputZipPath; } - - private void DownloadProvisioningProfiles() - { - if (string.IsNullOrEmpty(ProvisioningProfileUrl)) - { - return; - } - - string[] targets = AppBundles - .Select(appBundle => appBundle.TryGetMetadata(TargetPropName, out string target) ? target : null) - .Where(t => t != null) - .ToArray(); - - bool hasiOSTargets = targets.Contains("ios-device"); - bool hastvOSTargets = targets.Contains("tvos-device"); - - if (hasiOSTargets) - { - DownloadProvisioningProfile(TargetPlatform.iOS); - } - - if (hastvOSTargets) - { - DownloadProvisioningProfile(TargetPlatform.tvOS); - } - } - - private void DownloadProvisioningProfile(TargetPlatform platform) - { - var targetFile = Path.Combine(TmpDir, GetProvisioningProfileFileName(platform)); - - using var client = new WebClient(); - _helpers.DirectoryMutexExec(async () => { - if (File.Exists(targetFile)) - { - Log.LogMessage($"Provisioning profile is already downloaded"); - return; - } - - Log.LogMessage($"Downloading {platform} provisioning profile to {targetFile}"); - - await client.DownloadFileTaskAsync(new Uri(GetProvisioningProfileUrl(platform)), targetFile); - }, TmpDir); - } } } diff --git a/src/Microsoft.DotNet.Helix/Sdk/ProvisioningProfileProvider.cs b/src/Microsoft.DotNet.Helix/Sdk/ProvisioningProfileProvider.cs new file mode 100644 index 00000000000..4b8dd572c67 --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk/ProvisioningProfileProvider.cs @@ -0,0 +1,168 @@ +// 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.Collections.Generic; +using System.IO; +using System.Net.Http; +using Microsoft.Arcade.Common; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +#nullable enable +namespace Microsoft.DotNet.Helix.Sdk +{ + public enum ApplePlatform + { + iOS, + tvOS, + } + + public interface IProvisioningProfileProvider + { + void AddProfilesToBundles(ITaskItem[] appBundles); + } + + public class ProvisioningProfileProvider : IProvisioningProfileProvider + { + private static readonly IReadOnlyDictionary s_targetNames = new Dictionary() + { + { ApplePlatform.iOS, "ios-device" }, + { ApplePlatform.tvOS, "tvos-device" }, + }; + + private readonly TaskLoggingHelper _log; + private readonly IHelpers _helpers; + private readonly IFileSystem _fileSystem; + private readonly HttpClient _httpClient; + private readonly string? _profileUrlTemplate; + private readonly string? _tmpDir; + + public ProvisioningProfileProvider( + TaskLoggingHelper log, + IHelpers helpers, + IFileSystem fileSystem, + HttpClient httpClient, + string? profileUrlTemplate, + string? tmpDir) + { + _log = log ?? throw new ArgumentNullException(nameof(log)); + _helpers = helpers ?? throw new ArgumentNullException(nameof(helpers)); + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _profileUrlTemplate = profileUrlTemplate; + _tmpDir = tmpDir; + } + + public void AddProfilesToBundles(ITaskItem[] appBundles) + { + var profileLocations = new Dictionary(); + + foreach (var appBundle in appBundles) + { + if (!appBundle.TryGetMetadata(CreateXHarnessAppleWorkItems.TargetPropName, out string bundleTargets)) + { + _log.LogError("'Targets' metadata must be specified - " + + "expecting list of target device/simulator platforms to execute tests on (e.g. ios-simulator-64)"); + continue; + } + + foreach (var pair in s_targetNames) + { + var platform = pair.Key; + var targetName = pair.Value; + + if (!bundleTargets.Contains(targetName)) + { + continue; + } + + // App comes with a profile already + var provisioningProfileDestPath = _fileSystem.PathCombine(appBundle.ItemSpec, "embedded.mobileprovision"); + if (_fileSystem.FileExists(provisioningProfileDestPath)) + { + _log.LogMessage($"Bundle already contains a provisioning profile at `{provisioningProfileDestPath}`"); + continue; + } + + // This makes sure we download the profile the first time we see an app that needs it + if (!profileLocations.TryGetValue(platform, out string? profilePath)) + { + if (string.IsNullOrEmpty(_tmpDir)) + { + _log.LogError("TmpDir parameter not set but required for real device targets!"); + return; + } + + if (string.IsNullOrEmpty(_profileUrlTemplate)) + { + _log.LogError("ProvisioningProfileUrl parameter not set but required for real device targets!"); + return; + } + + profilePath = DownloadProvisioningProfile(platform); + profileLocations.Add(platform, profilePath); + } + + // Copy the profile into the folder + _log.LogMessage($"Adding provisioning profile `{profilePath}` into the app bundle at `{provisioningProfileDestPath}`"); + _fileSystem.FileCopy(profilePath, provisioningProfileDestPath); + } + } + } + + private string DownloadProvisioningProfile(ApplePlatform platform) + { + var targetFile = _fileSystem.PathCombine(_tmpDir!, GetProvisioningProfileFileName(platform)); + + _helpers.DirectoryMutexExec(async () => + { + if (_fileSystem.FileExists(targetFile)) + { + _log.LogMessage($"Provisioning profile is already downloaded"); + return; + } + + _log.LogMessage($"Downloading {platform} provisioning profile to {targetFile}"); + + var uri = new Uri(GetProvisioningProfileUrl(platform)); + using var response = await _httpClient.GetAsync(uri); + response.EnsureSuccessStatusCode(); + + using var fileStream = _fileSystem.GetFileStream(targetFile, FileMode.Create, FileAccess.Write); + await response.Content.CopyToAsync(fileStream); + }, _tmpDir); + + return targetFile; + } + + private string GetProvisioningProfileFileName(ApplePlatform platform) + => _fileSystem.GetFileName(GetProvisioningProfileUrl(platform)) + ?? throw new InvalidOperationException("Failed to get provision profile file name"); + + private string GetProvisioningProfileUrl(ApplePlatform platform) + => _profileUrlTemplate!.Replace("{PLATFORM}", platform.ToString()); + } + + public static class ProvisioningProfileProviderRegistration + { + public static void TryAddProvisioningProfileProvider(this IServiceCollection collection, string provisioningProfileUrlTemplate, string tmpDir) + { + collection.TryAddTransient(); + collection.TryAddTransient(); + collection.TryAddSingleton(_ => new HttpClient(new HttpClientHandler { CheckCertificateRevocationList = true })); + collection.TryAddSingleton(serviceProvider => + { + return new ProvisioningProfileProvider( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + provisioningProfileUrlTemplate, + tmpDir); + }); + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs b/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs index 53c8fd5db52..b861b8f8816 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs @@ -1,31 +1,22 @@ using System; -using System.IO; -using System.IO.Compression; -using System.Reflection; -using System.Threading.Tasks; +using Microsoft.Arcade.Common; using Microsoft.Build.Framework; -using Newtonsoft.Json; namespace Microsoft.DotNet.Helix.Sdk { /// /// MSBuild custom task to create HelixWorkItems for provided Android application packages. /// - public abstract class XHarnessTaskBase : BaseTask + public abstract class XHarnessTaskBase : MSBuildTaskBase { - private const int DefaultWorkItemTimeoutInMinutes = 20; - private const int DefaultTestTimeoutInMinutes = 12; + private static readonly TimeSpan s_defaultWorkItemTimeout = TimeSpan.FromMinutes(20); + private static readonly TimeSpan s_defaultTestTimeout = TimeSpan.FromMinutes(12); private const string TestTimeoutPropName = "TestTimeout"; private const string WorkItemTimeoutPropName = "WorkItemTimeout"; private const string ExpectedExitCodePropName = "ExpectedExitCode"; - /// - /// 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 - /// - [Required] - public bool IsPosixShell { get; set; } + protected const string ScriptNamespace = "tools.xharness_runner."; /// /// Extra arguments that will be passed to the iOS/Android/... app that is being run @@ -51,7 +42,7 @@ public abstract class XHarnessTaskBase : BaseTask protected (TimeSpan TestTimeout, TimeSpan WorkItemTimeout, int ExpectedExitCode) ParseMetadata(ITaskItem xHarnessAppItem) { // Optional timeout for the actual test execution in the TimeSpan format - TimeSpan testTimeout = TimeSpan.FromMinutes(DefaultTestTimeoutInMinutes); + TimeSpan testTimeout = s_defaultTestTimeout; if (xHarnessAppItem.TryGetMetadata(TestTimeoutPropName, out string testTimeoutProp)) { if (!TimeSpan.TryParse(testTimeoutProp, out testTimeout) || testTimeout.Ticks < 0) @@ -61,7 +52,7 @@ public abstract class XHarnessTaskBase : BaseTask } // Optional timeout for the whole Helix work item run (includes SDK and tool installation) - TimeSpan workItemTimeout = TimeSpan.FromMinutes(DefaultWorkItemTimeoutInMinutes); + TimeSpan workItemTimeout = s_defaultWorkItemTimeout; if (xHarnessAppItem.TryGetMetadata(WorkItemTimeoutPropName, out string workItemTimeoutProp)) { if (!TimeSpan.TryParse(workItemTimeoutProp, out workItemTimeout) || workItemTimeout.Ticks < 0) @@ -73,7 +64,7 @@ public abstract class XHarnessTaskBase : BaseTask { // When test timeout was set and work item timeout has not, // we adjust the work item timeout to give enough space for things to work - workItemTimeout = TimeSpan.FromMinutes(testTimeout.TotalMinutes + DefaultWorkItemTimeoutInMinutes - DefaultTestTimeoutInMinutes); + workItemTimeout = testTimeout + s_defaultWorkItemTimeout - s_defaultTestTimeout; } if (workItemTimeout <= testTimeout) @@ -94,26 +85,5 @@ public abstract class XHarnessTaskBase : BaseTask WorkItemTimeout: workItemTimeout, ExpectedExitCode: expectedExitCode); } - - protected static async Task AddResourceFileToPayload(string payloadArchivePath, string resourceFileName, string targetFileName = null) - { - using Stream fileStream = GetResourceFileContent(resourceFileName); - await AddToPayloadArchive(payloadArchivePath, targetFileName ?? resourceFileName, fileStream); - } - - protected static async Task AddToPayloadArchive(string payloadArchivePath, string targetFilename, Stream content) - { - using FileStream archiveStream = new FileStream(payloadArchivePath, FileMode.Open); - using ZipArchive archive = new ZipArchive(archiveStream, ZipArchiveMode.Update); - ZipArchiveEntry entry = archive.CreateEntry(targetFilename); - using Stream targetStream = entry.Open(); - await content.CopyToAsync(targetStream); - } - - protected static Stream GetResourceFileContent(string resourceFileName) - { - Assembly thisAssembly = typeof(XHarnessTaskBase).Assembly; - return thisAssembly.GetManifestResourceStream($"{thisAssembly.GetName().Name}.tools.xharness_runner.{resourceFileName}"); - } } } diff --git a/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/Readme.md b/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/Readme.md index cfea64e5f79..38ed2786dc4 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/Readme.md +++ b/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/Readme.md @@ -15,7 +15,7 @@ For these workloads, we currently expect the payload to contain xUnit tests and Logs will be collected automatically and sent back with the other Helix results. The test results themselves can be published to Azure DevOps using the same python-based publishing scripts as regular Helix jobs. -XHarness is a [.NET Core tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools) and requires **.NET Core 3.1 runtime** to execute on the Helix agent. +XHarness is a [.NET Core tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools) and requires **.NET 6 runtime** to execute on the Helix agent. This is automatically included as a Helix Correlation Payload for the job when XHarness workload is detected. ## How to use @@ -30,7 +30,7 @@ There are some required configuration properties that need to be set for XHarnes ```xml - + true @@ -45,7 +45,7 @@ There are some required configuration properties that need to be set for XHarnes - + ``` @@ -123,7 +123,7 @@ The Helix machines, that have devices attached to them, already contain the sign When using the Helix SDK and targeting real devices: - You have to ideally supply a non-signed app bundle - the app will be signed for you on the Helix machine where your job gets executed - Only the basic set of app permissions are supported at the moment and we cannot re-sign an app that was already signed with a different set of permissions -- Bundle id has to start with `net.dot.` since we only support those application IDs at the moment +- App bundle identifier has to start with `net.dot.` since we only support those application IDs at the moment ### Android .apk payloads diff --git a/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/XHarnessRunner.targets b/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/XHarnessRunner.targets index cd38f7766b3..d96450e5f79 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/XHarnessRunner.targets +++ b/src/Microsoft.DotNet.Helix/Sdk/tools/xharness-runner/XHarnessRunner.targets @@ -103,7 +103,6 @@ Condition=" '@(XHarnessAppBundleToTest)' != '' " BeforeTargets="CoreTest">