diff --git a/src/LibraryManager.Contracts/FileHelpers.cs b/src/LibraryManager.Contracts/FileHelpers.cs index 7092f8751..3d58e5d86 100644 --- a/src/LibraryManager.Contracts/FileHelpers.cs +++ b/src/LibraryManager.Contracts/FileHelpers.cs @@ -374,13 +374,25 @@ public static bool IsUnderRootDirectory(string filePath, string rootDirectory) && normalizedFilePath.StartsWith(normalizedRootDirectory, StringComparison.OrdinalIgnoreCase); } - internal static string NormalizePath(string path) + /// + /// Normalizes the path string so it can be easily compared. + /// + /// + /// Result will be lowercase and have any trailing slashes removed. + /// + public static string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) { return path; } + // If the path is a URI, we don't want to normalize it + if (IsHttpUri(path)) + { + return path; + } + // net451 does not have the OSPlatform apis to determine if the OS is windows or not. // This also does not handle the fact that MacOS can be configured to be either sensitive or insenstive // to the casing. @@ -394,5 +406,14 @@ internal static string NormalizePath(string path) return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + + /// + /// Determines if the path is an HTTP or HTTPS Uri + /// + public static bool IsHttpUri(string path) + { + return Uri.TryCreate(path, UriKind.Absolute, out Uri uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } } } diff --git a/src/LibraryManager.Contracts/IProvider.cs b/src/LibraryManager.Contracts/IProvider.cs index 8a43d551e..b6cd90d75 100644 --- a/src/LibraryManager.Contracts/IProvider.cs +++ b/src/LibraryManager.Contracts/IProvider.cs @@ -66,5 +66,10 @@ public interface IProvider /// /// string GetSuggestedDestination(ILibrary library); + + /// + /// Gets the goal state of the library installation. Does not imply actual installation. + /// + Task> GetInstallationGoalStateAsync(ILibraryInstallationState installationState, CancellationToken cancellationToken); } } diff --git a/src/LibraryManager.Contracts/LibraryInstallationGoalState.cs b/src/LibraryManager.Contracts/LibraryInstallationGoalState.cs new file mode 100644 index 000000000..3c6df96fa --- /dev/null +++ b/src/LibraryManager.Contracts/LibraryInstallationGoalState.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Web.LibraryManager.Contracts +{ + /// + /// Represents a goal state of deployed files mapped to their sources from the local cache + /// + public class LibraryInstallationGoalState + { + /// + /// Initialize a new goal state from the desired installation state. + /// + public LibraryInstallationGoalState(ILibraryInstallationState installationState, Dictionary installedFiles) + { + InstallationState = installationState; + InstalledFiles = installedFiles; + } + + /// + /// The ILibraryInstallationState that this goal state was computed from. + /// + public ILibraryInstallationState InstallationState { get; } + + /// + /// Mapping from destination file to source file + /// + public IDictionary InstalledFiles { get; } + + /// + /// Returns whether the goal is in an achieved state - that is, all files are up to date. + /// + /// + /// This is intended to serve as a fast check compared to restoring the files. + /// If there isn't a faster way to verify that a file is up to date, this method should + /// return false to indicate that a restore can't be skipped. + /// + public bool IsAchieved() + { + foreach (KeyValuePair kvp in InstalledFiles) + { + // If the source file is a remote Uri, we have no way to determine if it matches the installed file. + // So we will always reinstall the library in this case. + if (FileHelpers.IsHttpUri(kvp.Value)) + { + return false; + } + + var destinationFile = new FileInfo(kvp.Key); + var cacheFile = new FileInfo(kvp.Value); + + if (!destinationFile.Exists || !cacheFile.Exists || !FileHelpers.AreFilesUpToDate(destinationFile, cacheFile)) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/LibraryManager.Contracts/PredefinedErrors.cs b/src/LibraryManager.Contracts/PredefinedErrors.cs index 71873e9a9..cfcb41074 100644 --- a/src/LibraryManager.Contracts/PredefinedErrors.cs +++ b/src/LibraryManager.Contracts/PredefinedErrors.cs @@ -17,11 +17,11 @@ namespace Microsoft.Web.LibraryManager.Contracts public static class PredefinedErrors { /// - /// Represents an unhandled exception that occured in the provider. + /// Represents an unhandled exception that occurred in the provider. /// /// /// An should never throw and this error - /// should be used as when catching generic exeptions. + /// should be used as when catching generic exceptions. /// /// The error code LIB000 public static IError UnknownException() @@ -198,6 +198,12 @@ public static IError DuplicateLibrariesInManifest(string duplicateLibrary) public static IError FileNameMustNotBeEmpty(string libraryId) => new Error("LIB020", string.Format(Text.ErrorFilePathIsEmpty, libraryId)); + /// + /// A library mapping does not have a destination specified + /// + public static IError DestinationNotSpecified(string libraryId) + => new Error("LIB021", string.Format(Text.ErrorDestinationNotSpecified, libraryId)); + /// /// The manifest must specify a version /// diff --git a/src/LibraryManager.Contracts/Resources/Text.Designer.cs b/src/LibraryManager.Contracts/Resources/Text.Designer.cs index 4ca1b1506..5da2ffcbb 100644 --- a/src/LibraryManager.Contracts/Resources/Text.Designer.cs +++ b/src/LibraryManager.Contracts/Resources/Text.Designer.cs @@ -87,6 +87,15 @@ internal static string ErrorCouldNotWriteFile { } } + /// + /// Looks up a localized string similar to The "{0}" library is missing a destination.. + /// + internal static string ErrorDestinationNotSpecified { + get { + return ResourceManager.GetString("ErrorDestinationNotSpecified", resourceCulture); + } + } + /// /// Looks up a localized string similar to The "{0}" destination file path has invalid characters. /// diff --git a/src/LibraryManager.Contracts/Resources/Text.resx b/src/LibraryManager.Contracts/Resources/Text.resx index 4ed3c2637..3c0d9e51e 100644 --- a/src/LibraryManager.Contracts/Resources/Text.resx +++ b/src/LibraryManager.Contracts/Resources/Text.resx @@ -1,17 +1,17 @@  - @@ -187,6 +187,9 @@ Valid files are {2} The library "{0}" cannot specify a file with an empty name + + The "{0}" library is missing a destination. + The Library Manager manifest must specify a version. diff --git a/src/LibraryManager/Manifest.cs b/src/LibraryManager/Manifest.cs index 179a1b594..a8556214e 100644 --- a/src/LibraryManager/Manifest.cs +++ b/src/LibraryManager/Manifest.cs @@ -204,8 +204,6 @@ public async Task InstallLibraryAsync( string destination, CancellationToken cancellationToken) { - ILibraryOperationResult result; - var desiredState = new LibraryInstallationState() { Name = libraryName, @@ -236,14 +234,14 @@ public async Task InstallLibraryAsync( return conflictResults; } - result = await provider.InstallAsync(desiredState, cancellationToken).ConfigureAwait(false); + ILibraryOperationResult installResult = await provider.InstallAsync(desiredState, cancellationToken); - if (result.Success) + if (installResult.Success) { AddLibrary(desiredState); } - return result; + return installResult; } private ILibraryInstallationState SetDefaultProviderIfNeeded(LibraryInstallationState desiredState) @@ -510,7 +508,7 @@ private async Task> GetAllManifestFilesWithVersionsA return allFiles.SelectMany(f => f).Distinct(); } - return new List(); + return new List(); } private async Task> GetFilesWithVersionsAsync(ILibraryInstallationState state) @@ -567,41 +565,31 @@ private async Task DeleteLibraryFilesAsync(ILibraryInst try { IProvider provider = _dependencies.GetProvider(state.ProviderId); - ILibraryOperationResult updatedStateResult = await provider.UpdateStateAsync(state, CancellationToken.None).ConfigureAwait(false); - - if (updatedStateResult.Success) + OperationResult getGoalState = await provider.GetInstallationGoalStateAsync(state, cancellationToken).ConfigureAwait(false); + if (!getGoalState.Success) { - List filesToDelete = new List(); - state = updatedStateResult.InstallationState; - - foreach (string file in state.Files) + return new LibraryOperationResult(state, [.. getGoalState.Errors]) { - var url = new Uri(file, UriKind.RelativeOrAbsolute); - - if (!url.IsAbsoluteUri) - { - string relativePath = Path.Combine(state.DestinationPath, file).Replace('\\', '/'); - filesToDelete.Add(relativePath); - } - } + Cancelled = getGoalState.Cancelled, + }; + } - bool success = true; - if (deleteFilesFunction != null) - { - success = await deleteFilesFunction.Invoke(filesToDelete).ConfigureAwait(false); - } + LibraryInstallationGoalState goalState = getGoalState.Result; - if (success) - { - return LibraryOperationResult.FromSuccess(updatedStateResult.InstallationState); - } - else - { - return LibraryOperationResult.FromError(PredefinedErrors.CouldNotDeleteLibrary(libraryId)); - } + bool success = true; + if (deleteFilesFunction != null) + { + success = await deleteFilesFunction.Invoke(goalState.InstalledFiles.Keys).ConfigureAwait(false); } - return updatedStateResult; + if (success) + { + return LibraryOperationResult.FromSuccess(goalState.InstallationState); + } + else + { + return LibraryOperationResult.FromError(PredefinedErrors.CouldNotDeleteLibrary(libraryId)); + } } catch (OperationCanceledException) { diff --git a/src/LibraryManager/Providers/BaseProvider.cs b/src/LibraryManager/Providers/BaseProvider.cs index 03beaf6b0..60955bd15 100644 --- a/src/LibraryManager/Providers/BaseProvider.cs +++ b/src/LibraryManager/Providers/BaseProvider.cs @@ -16,7 +16,7 @@ namespace Microsoft.Web.LibraryManager.Providers { /// - /// Default implenentation for a provider, since most provider implementations are very similar. + /// Default implementation for a provider, since most provider implementations are very similar. /// internal abstract class BaseProvider : IProvider { @@ -60,30 +60,98 @@ public virtual async Task InstallAsync(ILibraryInstalla return LibraryOperationResult.FromCancelled(desiredState); } - //Expand the files property if needed - ILibraryOperationResult updateResult = await UpdateStateAsync(desiredState, cancellationToken); - if (!updateResult.Success) + OperationResult getLibrary = await GetLibraryForInstallationState(desiredState, cancellationToken).ConfigureAwait(false); + if (!getLibrary.Success) { - return updateResult; + return new LibraryOperationResult(desiredState, [.. getLibrary.Errors]) + { + Cancelled = getLibrary.Cancelled, + }; + } + + OperationResult getGoalState = GenerateGoalState(desiredState, getLibrary.Result); + if (!getGoalState.Success) + { + return new LibraryOperationResult(desiredState, [.. getGoalState.Errors]) + { + Cancelled = getGoalState.Cancelled, + }; } - desiredState = updateResult.InstallationState; + LibraryInstallationGoalState goalState = getGoalState.Result; - // Refresh cache if needed - ILibraryOperationResult cacheUpdateResult = await RefreshCacheAsync(desiredState, cancellationToken); - if (!cacheUpdateResult.Success) + if (!IsSourceCacheReady(goalState)) { - return cacheUpdateResult; + ILibraryOperationResult updateCacheResult = await RefreshCacheAsync(desiredState, getLibrary.Result, cancellationToken); + if (!updateCacheResult.Success) + { + return updateCacheResult; + } } - // Check if Library is already up to date - if (IsLibraryUpToDate(desiredState)) + if (goalState.IsAchieved()) { return LibraryOperationResult.FromUpToDate(desiredState); } - // Write files to destination - return await WriteToFilesAsync(desiredState, cancellationToken); + return await InstallFiles(goalState, cancellationToken); + + } + + private async Task> GetLibraryForInstallationState(ILibraryInstallationState desiredState, CancellationToken cancellationToken) + { + ILibrary library; + try + { + ILibraryCatalog catalog = GetCatalog(); + library = await catalog.GetLibraryAsync(desiredState.Name, desiredState.Version, cancellationToken).ConfigureAwait(false); + } + catch (InvalidLibraryException) + { + string libraryId = LibraryNamingScheme.GetLibraryId(desiredState.Name, desiredState.Version); + return OperationResult.FromError(PredefinedErrors.UnableToResolveSource(libraryId, desiredState.ProviderId)); + } + catch (Exception ex) + { + HostInteraction.Logger.Log(ex.ToString(), LogLevel.Error); + return OperationResult.FromError(PredefinedErrors.UnknownException()); + } + + return OperationResult.FromSuccess(library); + } + + private async Task InstallFiles(LibraryInstallationGoalState goalState, CancellationToken cancellationToken) + { + try + { + foreach (KeyValuePair kvp in goalState.InstalledFiles) + { + if (cancellationToken.IsCancellationRequested) + { + return LibraryOperationResult.FromCancelled(goalState.InstallationState); + } + + string sourcePath = kvp.Value; + string destinationPath = kvp.Key; + bool writeOk = await HostInteraction.CopyFileAsync(sourcePath, destinationPath, cancellationToken); + + if (!writeOk) + { + return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.CouldNotWriteFile(destinationPath)); + } + } + } + catch (UnauthorizedAccessException) + { + return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.PathOutsideWorkingDirectory()); + } + catch (Exception ex) + { + HostInteraction.Logger.Log(ex.ToString(), LogLevel.Error); + return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.UnknownException()); + } + + return LibraryOperationResult.FromSuccess(goalState.InstallationState); } /// @@ -163,8 +231,90 @@ public virtual async Task UpdateStateAsync(ILibraryInst return LibraryOperationResult.FromSuccess(desiredState); } + public async Task> GetInstallationGoalStateAsync(ILibraryInstallationState desiredState, CancellationToken cancellationToken) + { + // get the library from the catalog + OperationResult getLibrary = await GetLibraryForInstallationState(desiredState, cancellationToken).ConfigureAwait(false); + if (!getLibrary.Success) + { + return OperationResult.FromErrors([.. getLibrary.Errors]); + } + + return GenerateGoalState(desiredState, getLibrary.Result); + } + #endregion + private OperationResult GenerateGoalState(ILibraryInstallationState desiredState, ILibrary library) + { + List errors = null; + + if (string.IsNullOrEmpty(desiredState.DestinationPath)) + { + return OperationResult.FromError(PredefinedErrors.DestinationNotSpecified(desiredState.Name)); + } + + IEnumerable outFiles; + if (desiredState.Files == null || desiredState.Files.Count == 0) + { + outFiles = library.Files.Keys; + } + else + { + outFiles = FileGlobbingUtility.ExpandFileGlobs(desiredState.Files, library.Files.Keys); + } + + Dictionary installFiles = new(); + if (library.GetInvalidFiles(outFiles.ToList()) is IReadOnlyList invalidFiles + && invalidFiles.Count > 0) + { + errors ??= []; + errors.Add(PredefinedErrors.InvalidFilesInLibrary(desiredState.Name, invalidFiles, library.Files.Keys)); + } + + foreach (string outFile in outFiles) + { + // strip the source prefix + string destinationFile = Path.Combine(HostInteraction.WorkingDirectory, desiredState.DestinationPath, outFile); + if (!FileHelpers.IsUnderRootDirectory(destinationFile, HostInteraction.WorkingDirectory)) + { + errors ??= []; + errors.Add(PredefinedErrors.PathOutsideWorkingDirectory()); + } + destinationFile = FileHelpers.NormalizePath(destinationFile); + + // don't forget to include the cache folder in the path + string sourceFile = GetCachedFileLocalPath(desiredState, outFile); + sourceFile = FileHelpers.NormalizePath(sourceFile); + + // map destination back to the library-relative file it originated from + installFiles.Add(destinationFile, sourceFile); + } + + if (errors is not null) + { + return OperationResult.FromErrors([.. errors]); + } + + var goalState = new LibraryInstallationGoalState(desiredState, installFiles); + return OperationResult.FromSuccess(goalState); + } + + public bool IsSourceCacheReady(LibraryInstallationGoalState goalState) + { + foreach (KeyValuePair item in goalState.InstalledFiles) + { + string cachePath = GetCachedFileLocalPath(goalState.InstallationState, item.Value); + // TODO: use abstraction for filesystem ops + if (!File.Exists(cachePath)) + { + return false; + } + } + + return true; + } + protected virtual ILibraryOperationResult CheckForInvalidFiles(ILibraryInstallationState desiredState, string libraryId, ILibrary library) { IReadOnlyList invalidFiles = library.GetInvalidFiles(desiredState.Files); @@ -237,38 +387,9 @@ protected async Task WriteToFilesAsync(ILibraryInstalla /// Gets the expected local path for a file from the file cache /// /// - private string GetCachedFileLocalPath(ILibraryInstallationState state, string sourceFile) + protected virtual string GetCachedFileLocalPath(ILibraryInstallationState state, string sourceFile) { - return Path.Combine(CacheFolder, state.Name, state.Version, sourceFile); - } - - private bool IsLibraryUpToDate(ILibraryInstallationState state) - { - try - { - if (!string.IsNullOrEmpty(state.Name) && !string.IsNullOrEmpty(state.Version)) - { - string cacheDir = Path.Combine(CacheFolder, state.Name, state.Version); - string destinationDir = Path.Combine(HostInteraction.WorkingDirectory, state.DestinationPath); - - foreach (string sourceFile in state.Files) - { - var destinationFile = new FileInfo(Path.Combine(destinationDir, sourceFile).Replace('\\', '/')); - var cacheFile = new FileInfo(Path.Combine(cacheDir, sourceFile).Replace('\\', '/')); - - if (!destinationFile.Exists || !cacheFile.Exists || !FileHelpers.AreFilesUpToDate(destinationFile, cacheFile)) - { - return false; - } - } - } - } - catch - { - return false; - } - - return true; + return Path.Combine(CacheFolder, state.Name, state.Version, sourceFile.Trim('/')); } /// @@ -277,7 +398,7 @@ private bool IsLibraryUpToDate(ILibraryInstallationState state) /// /// /// - private async Task RefreshCacheAsync(ILibraryInstallationState state, CancellationToken cancellationToken) + private async Task RefreshCacheAsync(ILibraryInstallationState state, ILibrary library, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -288,8 +409,19 @@ private async Task RefreshCacheAsync(ILibraryInstallati try { + IEnumerable filesToCache; + // expand "files" to concrete files in the library + if (state.Files == null || state.Files.Count == 0) + { + filesToCache = library.Files.Keys; + } + else + { + filesToCache = FileGlobbingUtility.ExpandFileGlobs(state.Files, library.Files.Keys); + } + var librariesMetadata = new HashSet(); - foreach (string sourceFile in state.Files) + foreach (string sourceFile in filesToCache) { string cacheFile = Path.Combine(libraryDir, sourceFile); string url = GetDownloadUrl(state, sourceFile); diff --git a/src/LibraryManager/Providers/FileSystem/FileSystemProvider.cs b/src/LibraryManager/Providers/FileSystem/FileSystemProvider.cs index 51afa5c65..27317660f 100644 --- a/src/LibraryManager/Providers/FileSystem/FileSystemProvider.cs +++ b/src/LibraryManager/Providers/FileSystem/FileSystemProvider.cs @@ -206,5 +206,28 @@ protected override string GetDownloadUrl(ILibraryInstallationState state, string { throw new NotSupportedException(); } + + protected override string GetCachedFileLocalPath(ILibraryInstallationState state, string sourceFile) + { + // FileSystemProvider pulls files directly, no caching. So here we need to build a full + // path or URI to the file. + + // For HTTP files, the state.Name is the full URL to a single file + if (FileHelpers.IsHttpUri(state.Name)) + { + return state.Name; + } + + // For other filesystem libraries, the state.Name may be a either a file or folder + // TODO: abstract file system + if (File.Exists(state.Name)) + { + return state.Name; + } + + // as a fallback, assume state.Name is a directory. If this path doesn't exist, it will + // be handled elsewhere. + return Path.Combine(state.Name, sourceFile); + } } } diff --git a/test/LibraryManager.Mocks/Provider.cs b/test/LibraryManager.Mocks/Provider.cs index 41f999180..6f0d1cc45 100644 --- a/test/LibraryManager.Mocks/Provider.cs +++ b/test/LibraryManager.Mocks/Provider.cs @@ -58,6 +58,11 @@ public Provider(IHostInteraction hostInteraction) /// public virtual ILibraryOperationResult Result { get; set; } + /// + /// Gets or sets the goal state to return from + /// + public LibraryInstallationGoalState GoalState { get; set; } + /// /// Indicates whether libraries with versions are supported. /// @@ -105,5 +110,13 @@ public string GetSuggestedDestination(ILibrary library) { return library?.Name; } + + /// + /// Returns a stubbed value + /// + public Task> GetInstallationGoalStateAsync(ILibraryInstallationState installationState, CancellationToken cancellationToken) + { + return Task.FromResult(OperationResult.FromSuccess(GoalState)); + } } } diff --git a/test/LibraryManager.Test/Providers/BaseProviderTest.cs b/test/LibraryManager.Test/Providers/BaseProviderTest.cs new file mode 100644 index 000000000..91368d37d --- /dev/null +++ b/test/LibraryManager.Test/Providers/BaseProviderTest.cs @@ -0,0 +1,139 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Web.LibraryManager.Cache; +using Microsoft.Web.LibraryManager.Contracts; +using Microsoft.Web.LibraryManager.Providers; +using Microsoft.Web.LibraryManager.Resources; + +namespace Microsoft.Web.LibraryManager.Test.Providers +{ + [TestClass] + public class BaseProviderTest + { + private IHostInteraction _hostInteraction; + private ILibrary _library; + private readonly Mocks.LibraryCatalog _catalog; + + public BaseProviderTest() + { + _hostInteraction = new Mocks.HostInteraction() + { + CacheDirectory = "C:\\cache", + WorkingDirectory = "C:\\project", + }; + + _library = new Mocks.Library() + { + Name = "test", + Version = "1.0", + ProviderId = "TestProvider", + Files = new Dictionary() + { + { "file1.txt", true }, + { "file2.txt", false }, + { "folder/file3.txt", false }, + }, + }; + + _catalog = new Mocks.LibraryCatalog() + .AddLibrary(_library); + } + + [TestMethod] + public async Task GenerateGoalState_NoFileMapping_SpecifyFilesAtLibraryLevel() + { + ILibraryInstallationState installState = new LibraryInstallationState + { + Name = "test", + Version = "1.0", + ProviderId = "TestProvider", + DestinationPath = "lib/test", + Files = ["folder/*.txt"], + }; + BaseProvider provider = new TestProvider(_hostInteraction, cacheService: null) + { + Catalog = _catalog, + }; + string expectedDestinationFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/folder/file3.txt")); + string expectedSourceFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/folder/file3.txt")); + + OperationResult getGoalState = await provider.GetInstallationGoalStateAsync(installState, CancellationToken.None); + + Assert.IsTrue(getGoalState.Success); + LibraryInstallationGoalState goalState = getGoalState.Result; + Assert.IsNotNull(goalState); + Assert.AreEqual(1, goalState.InstalledFiles.Count); + Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile1, out string file1)); + Assert.AreEqual(expectedSourceFile1, file1); + } + + [TestMethod] + public async Task GenerateGoalState_NoFileMapping_NoFilesAtLibraryLevel() + { + ILibraryInstallationState installState = new LibraryInstallationState + { + Name = "test", + Version = "1.0", + ProviderId = "TestProvider", + DestinationPath = "lib/test", + }; + BaseProvider provider = new TestProvider(_hostInteraction, cacheService: null) + { + Catalog = _catalog, + }; + string expectedDestinationFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/file1.txt")); + string expectedSourceFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/file1.txt")); + string expectedDestinationFile2 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/file2.txt")); + string expectedSourceFile2 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/file2.txt")); + string expectedDestinationFile3 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/folder/file3.txt")); + string expectedSourceFile3 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/folder/file3.txt")); + + OperationResult getGoalState = await provider.GetInstallationGoalStateAsync(installState, CancellationToken.None); + + Assert.IsTrue(getGoalState.Success); + LibraryInstallationGoalState goalState = getGoalState.Result; + + Assert.IsNotNull(goalState); + Assert.AreEqual(3, goalState.InstalledFiles.Count); + Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile1, out string file1)); + Assert.AreEqual(expectedSourceFile1, file1); + Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile2, out string file2)); + Assert.AreEqual(expectedSourceFile2, file2); + Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile3, out string file3)); + Assert.AreEqual(expectedSourceFile3, file3); + } + + private class TestProvider : BaseProvider + { + public TestProvider(IHostInteraction hostInteraction, CacheService cacheService) + : base(hostInteraction, cacheService) + { + } + + public override string Id => nameof(TestProvider); + + public override string LibraryIdHintText => Text.CdnjsLibraryIdHintText; + + public ILibraryCatalog Catalog { get; set; } + + public override ILibraryCatalog GetCatalog() => Catalog; + + public override string GetSuggestedDestination(ILibrary library) + { + throw new NotImplementedException(); + } + + protected override string GetDownloadUrl(ILibraryInstallationState state, string sourceFile) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/LibraryManager.Test/Providers/Cdnjs/CdnjsProviderTest.cs b/test/LibraryManager.Test/Providers/Cdnjs/CdnjsProviderTest.cs index e051d8ed9..8bb330564 100644 --- a/test/LibraryManager.Test/Providers/Cdnjs/CdnjsProviderTest.cs +++ b/test/LibraryManager.Test/Providers/Cdnjs/CdnjsProviderTest.cs @@ -100,8 +100,7 @@ public async Task InstallAsync_NoPathDefined() ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsFalse(result.Success); - // Unknown exception. We no longer validate ILibraryState at the provider level - Assert.AreEqual("LIB000", result.Errors[0].Code); + Assert.AreEqual("LIB021", result.Errors[0].Code); } [TestMethod] @@ -148,11 +147,16 @@ public async Task InstallAsync_WithGlobPatterns_CorrectlyInstallsAllMatchingFile Files = new[] { "*.js", "!*.min.js" }, }; + // Verify expansion of Files + OperationResult getGoalState = await _provider.GetInstallationGoalStateAsync(desiredState, CancellationToken.None); + Assert.IsTrue(getGoalState.Success); + LibraryInstallationGoalState goalState = getGoalState.Result; + Assert.AreEqual(1, goalState.InstalledFiles.Count); + Assert.AreEqual("jquery.js", Path.GetFileName(goalState.InstalledFiles.Keys.First())); + // Install library ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsTrue(result.Success); - Assert.IsTrue(result.InstallationState.Files.Count == 1); // jquery.min.js file was excluded - Assert.AreEqual("jquery.js", result.InstallationState.Files.First()); } [TestMethod] diff --git a/test/LibraryManager.Test/Providers/JsDelivr/JsDelivrProviderTest.cs b/test/LibraryManager.Test/Providers/JsDelivr/JsDelivrProviderTest.cs index c1ca36236..8f19e162e 100644 --- a/test/LibraryManager.Test/Providers/JsDelivr/JsDelivrProviderTest.cs +++ b/test/LibraryManager.Test/Providers/JsDelivr/JsDelivrProviderTest.cs @@ -100,8 +100,7 @@ public async Task InstallAsync_NoPathDefined() ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsFalse(result.Success); - // Unknown exception. We no longer validate ILibraryState at the provider level - Assert.AreEqual("LIB000", result.Errors[0].Code); + Assert.AreEqual("LIB021", result.Errors[0].Code); } [TestMethod] @@ -148,10 +147,17 @@ public async Task InstallAsync_WithGlobPatterns_CorrectlyInstallsAllMatchingFile Files = new[] { "dist/*.js", "!dist/*min*" }, }; + // Verify expansion of Files + OperationResult getGoalState = await _provider.GetInstallationGoalStateAsync(desiredState, CancellationToken.None); + Assert.IsTrue(getGoalState.Success); + LibraryInstallationGoalState goalState = getGoalState.Result; + // Remove the project folder and "/lib/" from the file paths + List installedFiles = goalState.InstalledFiles.Keys.Select(f => f.Substring(_projectFolder.Length + 5).Replace("\\", "/")).ToList(); + CollectionAssert.AreEquivalent(new[] { "dist/core.js", "dist/jquery.js", "dist/jquery.slim.js" }, installedFiles); + // Install library ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsTrue(result.Success); - CollectionAssert.AreEquivalent(new[] { "dist/core.js", "dist/jquery.js", "dist/jquery.slim.js" }, result.InstallationState.Files.ToList()); } [TestMethod] diff --git a/test/LibraryManager.Test/Providers/Unpkg/UnpkgProviderTest.cs b/test/LibraryManager.Test/Providers/Unpkg/UnpkgProviderTest.cs index d86367570..9947089a2 100644 --- a/test/LibraryManager.Test/Providers/Unpkg/UnpkgProviderTest.cs +++ b/test/LibraryManager.Test/Providers/Unpkg/UnpkgProviderTest.cs @@ -99,8 +99,7 @@ public async Task InstallAsync_NoPathDefined() ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsFalse(result.Success); - // Unknown exception. We no longer validate ILibraryState at the provider level - Assert.AreEqual("LIB000", result.Errors[0].Code); + Assert.AreEqual("LIB021", result.Errors[0].Code); } [TestMethod] @@ -147,10 +146,17 @@ public async Task InstallAsync_WithGlobPatterns_CorrectlyInstallsAllMatchingFile Files = new[] { "dist/*.js", "!dist/*min*" }, }; + // Verify expansion of Files + OperationResult getGoalState = await _provider.GetInstallationGoalStateAsync(desiredState, CancellationToken.None); + Assert.IsTrue(getGoalState.Success); + LibraryInstallationGoalState goalState = getGoalState.Result; + // Remove the project folder and "/lib/" from the file paths + List installedFiles = goalState.InstalledFiles.Keys.Select(f => f.Substring(_projectFolder.Length + 5).Replace("\\", "/")).ToList(); + CollectionAssert.AreEquivalent(new[] { "dist/core.js", "dist/jquery.js", "dist/jquery.slim.js" }, installedFiles); + // Install library ILibraryOperationResult result = await _provider.InstallAsync(desiredState, CancellationToken.None).ConfigureAwait(false); Assert.IsTrue(result.Success); - CollectionAssert.AreEquivalent(new[] { "dist/core.js", "dist/jquery.js", "dist/jquery.slim.js" }, result.InstallationState.Files.ToList()); } [TestMethod] diff --git a/test/Microsoft.Web.LibraryManager.Vsix.Test/Shared/LibraryCommandServiceTest.cs b/test/Microsoft.Web.LibraryManager.Vsix.Test/Shared/LibraryCommandServiceTest.cs index 62e306fe6..990a6de8f 100644 --- a/test/Microsoft.Web.LibraryManager.Vsix.Test/Shared/LibraryCommandServiceTest.cs +++ b/test/Microsoft.Web.LibraryManager.Vsix.Test/Shared/LibraryCommandServiceTest.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; @@ -28,6 +29,17 @@ public async Task UninstallAsync_DeletesFilesFromDisk() var mockTaskStatusCenterService = new Mock(); mockTaskStatusCenterService.Setup(m => m.CreateTaskHandlerAsync(It.IsAny())) .Returns(Task.FromResult(new Mock().Object)); + var testInstallationState = new LibraryInstallationState + { + ProviderId = "testProvider", + Files = new[] { "test.js" }, + DestinationPath = "testDestination", + }; + Dictionary installedFiles = new() + { + { Path.Combine(mockInteraction.WorkingDirectory, "testDestination", "test.js"), Path.Combine(mockInteraction.WorkingDirectory, "test.js")} + }; + var testGoalState = new LibraryInstallationGoalState(testInstallationState, installedFiles); var mockDependencies = new Dependencies(mockInteraction, new IProvider[] { new Mocks.Provider(mockInteraction) @@ -36,13 +48,9 @@ public async Task UninstallAsync_DeletesFilesFromDisk() Catalog = new Mocks.LibraryCatalog(), Result = new LibraryOperationResult { - InstallationState = new LibraryInstallationState - { - ProviderId = "testProvider", - Files = new [] { "test.js" }, - DestinationPath = "testDestination", - } + InstallationState = testInstallationState }, + GoalState = testGoalState, SupportsLibraryVersions = true, } });