diff --git a/src/code/ArgumentCompleter.cs b/src/code/ArgumentCompleter.cs index 1aaf1da10..e3567f424 100644 --- a/src/code/ArgumentCompleter.cs +++ b/src/code/ArgumentCompleter.cs @@ -7,6 +7,37 @@ using System.Management.Automation; using System.Management.Automation.Language; +internal class TargetFrameworkCompleter : IArgumentCompleter +{ + private static readonly string[] s_knownTargetFrameworks = new[] + { + "net462", "net472", "net48", + "netstandard2.0", "netstandard2.1", + "net6.0", "net7.0", "net8.0", "net9.0", "net10.0", + }; + + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) + { + wordToComplete = Utils.TrimQuotes(wordToComplete); + var wordToCompletePattern = WildcardPattern.Get( + pattern: string.IsNullOrWhiteSpace(wordToComplete) ? "*" : wordToComplete + "*", + options: WildcardOptions.IgnoreCase); + + foreach (string targetFramework in s_knownTargetFrameworks) + { + if (wordToCompletePattern.IsMatch(targetFramework)) + { + yield return new CompletionResult(targetFramework); + } + } + } +} + internal class RepositoryNameCompleter : IArgumentCompleter { public IEnumerable CompleteArgument( diff --git a/src/code/InstallHelper.cs b/src/code/InstallHelper.cs index 45b400489..6941c52a1 100644 --- a/src/code/InstallHelper.cs +++ b/src/code/InstallHelper.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.PowerShell.PSResourceGet.UtilClasses; +using NuGet.Frameworks; using NuGet.Protocol.Core.Types; using NuGet.Versioning; using System; @@ -53,6 +54,9 @@ internal class InstallHelper private bool _noClobber; private bool _authenticodeCheck; private bool _savePkg; + private bool _filterByTargetFramework; + private bool _targetFrameworkSpecified; + private NuGetFramework _targetFramework; List _pathsToSearch; List _pkgNamesToInstall; private string _tmpPath; @@ -99,7 +103,9 @@ public IEnumerable BeginInstallPackages( List pathsToInstallPkg, ScopeType? scope, string tmpPath, - HashSet pkgsInstalled) + HashSet pkgsInstalled, + bool filterByTargetFramework = false, + string targetFramework = null) { _cmdletPassedIn.WriteDebug("In InstallHelper::BeginInstallPackages()"); _cmdletPassedIn.WriteDebug(string.Format("Parameters passed in >>> Name: '{0}'; VersionRange: '{1}'; NuGetVersion: '{2}'; VersionType: '{3}'; Version: '{4}'; Prerelease: '{5}'; Repository: '{6}'; " + @@ -141,6 +147,44 @@ public IEnumerable BeginInstallPackages( _asNupkg = asNupkg; _includeXml = includeXml; _savePkg = savePkg; + _filterByTargetFramework = filterByTargetFramework; + _targetFrameworkSpecified = _filterByTargetFramework && !string.IsNullOrWhiteSpace(targetFramework); + _targetFramework = null; + + if (_filterByTargetFramework) + { + if (_targetFrameworkSpecified) + { + try + { + _targetFramework = NuGetFramework.ParseFolder(targetFramework); + } + catch (Exception e) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Target framework '{targetFramework}' is not valid.", e), + "InvalidTargetFramework", + ErrorCategory.InvalidArgument, + targetFramework)); + } + + if (_targetFramework == null || _targetFramework.IsUnsupported) + { + _cmdletPassedIn.ThrowTerminatingError(new ErrorRecord( + new ArgumentException($"Target framework '{targetFramework}' is not valid."), + "InvalidTargetFramework", + ErrorCategory.InvalidArgument, + targetFramework)); + } + } + else + { + _targetFramework = GetCurrentFramework(); + } + + _cmdletPassedIn.WriteDebug($"Filtering lib assets for target framework '{_targetFramework.GetShortFolderName()}'."); + } + _pathsToInstallPkg = pathsToInstallPkg; _tmpPath = tmpPath ?? Path.GetTempPath(); @@ -402,7 +446,8 @@ private void MoveFilesIntoInstallPath( string installPath, string newVersion, string moduleManifestVersion, - string scriptPath) + string scriptPath, + bool mergeTargetFrameworkContent = false) { _cmdletPassedIn.WriteDebug("In InstallHelper::MoveFilesIntoInstallPath()"); // Creating the proper installation path depending on whether pkg is a module or script @@ -425,6 +470,13 @@ private void MoveFilesIntoInstallPath( Directory.CreateDirectory(newPathParent); Utils.MoveDirectory(tempModuleVersionDir, finalModuleVersionDir); } + else if (mergeTargetFrameworkContent && Directory.Exists(finalModuleVersionDir)) + { + _cmdletPassedIn.WriteVerbose( + $"Merging target framework content from '{tempModuleVersionDir}' into '{finalModuleVersionDir}'"); + Utils.MergeDirContents(tempModuleVersionDir, finalModuleVersionDir); + Utils.DeleteDirectory(tempModuleVersionDir); + } else { _cmdletPassedIn.WriteVerbose($"Temporary module version directory is: '{tempModuleVersionDir}'"); @@ -555,15 +607,27 @@ private List InstallPackages( continue; } + bool mergeTargetFrameworkContent = false; + // Check to see if the pkg is already installed (unless -Reinstall was specified). if (!_reinstall && _packagesOnMachine.Contains($"{pkgToInstall.Name}{pkgVersion}")) { - _cmdletPassedIn.WriteWarning($"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. If you would like to reinstall, please run the cmdlet again with the -Reinstall parameter"); + if (_targetFrameworkSpecified && pkgToInstall.Type != ResourceType.Script) + { + _cmdletPassedIn.WriteVerbose( + $"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. " + + $"Proceeding to merge assets for target framework '{_targetFramework.GetShortFolderName()}'."); + mergeTargetFrameworkContent = true; + } + else + { + _cmdletPassedIn.WriteWarning($"Resource '{pkgToInstall.Name}' with version '{pkgVersion}' is already installed. If you would like to reinstall, please run the cmdlet again with the -Reinstall parameter"); - // Remove from tracking list of packages to install. - _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); + // Remove from tracking list of packages to install. + _pkgNamesToInstall.RemoveAll(x => x.Equals(pkgToInstall.Name, StringComparison.InvariantCultureIgnoreCase)); - continue; + continue; + } } // ShouldProcess gate (handles -WhatIf / -Confirm) on the pipeline thread. @@ -579,7 +643,8 @@ private List InstallPackages( { PkgToInstall = pkgToInstall, PkgVersion = pkgVersion, - TempInstallPath = CreateInstallationTempPath() + TempInstallPath = CreateInstallationTempPath(), + MergeTargetFrameworkContent = mergeTargetFrameworkContent }); } @@ -632,7 +697,11 @@ private List InstallPackages( // Parent package and dependencies are now installed to temp directory. // Try to move all package directories from temp directory to final destination. - if (!TryMoveInstallContent(workItem.TempInstallPath, scope, workItem.PackagesHash)) + if (!TryMoveInstallContent( + workItem.TempInstallPath, + scope, + workItem.PackagesHash, + workItem.MergeTargetFrameworkContent)) { _cmdletPassedIn.WriteError(new ErrorRecord( new InvalidOperationException(), @@ -683,6 +752,7 @@ private sealed class ParentInstallWorkItem public string TempInstallPath; public ConcurrentDictionary PackagesHash; public bool Succeeded; + public bool MergeTargetFrameworkContent; public readonly ConcurrentQueue ErrorMsgs = new(); public readonly ConcurrentQueue WarningMsgs = new(); public readonly ConcurrentQueue DebugMsgs = new(); @@ -1302,8 +1372,44 @@ private bool TryExtractToDirectory( { using (ZipArchive archive = ZipFile.OpenRead(zipPath)) { + HashSet selectedLibAssetEntries = null; + if (_filterByTargetFramework) + { + selectedLibAssetEntries = GetCompatibleLibAssetEntries( + archive, + _targetFramework, + out HashSet availableTargetFrameworks); + + debugMsgs.Enqueue( + $"Selected {selectedLibAssetEntries.Count} compatible lib asset entries for target framework " + + $"'{_targetFramework.GetShortFolderName()}'."); + + if (_targetFrameworkSpecified && + availableTargetFrameworks.Count > 0 && + selectedLibAssetEntries.Count == 0) + { + warningMsgs.Enqueue( + $"No compatible lib assets were found for target framework '{_targetFramework.GetShortFolderName()}'. " + + $"Available target frameworks: {string.Join(", ", availableTargetFrameworks.OrderBy(tfm => tfm))}."); + } + } + foreach (ZipArchiveEntry entry in archive.Entries.Where(entry => entry.CompressedLength > 0)) { + if (selectedLibAssetEntries != null && + TryGetLibAssetInfo( + entry.FullName, + out string normalizedEntryName, + out string _, + out NuGetFramework _) && + !selectedLibAssetEntries.Contains(normalizedEntryName)) + { + debugMsgs.Enqueue( + $"Skipping lib asset '{entry.FullName}' because it is not the nearest compatible variant " + + $"for target framework '{_targetFramework.GetShortFolderName()}'."); + continue; + } + // If a file has one or more parent directories. if (entry.FullName.Contains(Path.DirectorySeparatorChar) || entry.FullName.Contains(Path.AltDirectorySeparatorChar)) { @@ -1345,10 +1451,164 @@ private bool TryExtractToDirectory( return true; } + /// + /// Selects lib assets independently by relative path. For duplicate assets, only the nearest compatible + /// framework variant is selected. Unique compatible assets remain selected even when they are spread + /// across different target framework folders. + /// + private static HashSet GetCompatibleLibAssetEntries( + ZipArchive archive, + NuGetFramework targetFramework, + out HashSet availableTargetFrameworks) + { + var candidatesByRelativePath = new Dictionary>(StringComparer.OrdinalIgnoreCase); + availableTargetFrameworks = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (!TryGetLibAssetInfo( + entry.FullName, + out string normalizedEntryName, + out string relativePath, + out NuGetFramework entryFramework)) + { + continue; + } + + availableTargetFrameworks.Add(entryFramework.GetShortFolderName()); + + if (!candidatesByRelativePath.TryGetValue(relativePath, out List candidates)) + { + candidates = new List(); + candidatesByRelativePath.Add(relativePath, candidates); + } + + candidates.Add(new LibAssetCandidate(normalizedEntryName, entryFramework)); + } + + var selectedEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + var reducer = new FrameworkReducer(); + + foreach (List candidates in candidatesByRelativePath.Values) + { + NuGetFramework nearestFramework = reducer.GetNearest( + targetFramework, + candidates.Select(candidate => candidate.Framework)); + + if (nearestFramework == null) + { + continue; + } + + foreach (LibAssetCandidate candidate in candidates) + { + if (candidate.Framework.Equals(nearestFramework)) + { + selectedEntries.Add(candidate.EntryName); + } + } + } + + return selectedEntries; + } + + private static bool TryGetLibAssetInfo( + string entryFullName, + out string normalizedEntryName, + out string relativePath, + out NuGetFramework framework) + { + normalizedEntryName = entryFullName.Replace('\\', '/'); + relativePath = null; + framework = null; + + if (!normalizedEntryName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int frameworkEndIndex = normalizedEntryName.IndexOf('/', "lib/".Length); + if (frameworkEndIndex <= "lib/".Length || frameworkEndIndex == normalizedEntryName.Length - 1) + { + return false; + } + + string frameworkFolder = normalizedEntryName.Substring( + "lib/".Length, + frameworkEndIndex - "lib/".Length); + + try + { + framework = NuGetFramework.ParseFolder(frameworkFolder); + } + catch + { + return false; + } + + if (framework == null || framework.IsUnsupported) + { + framework = null; + return false; + } + + relativePath = normalizedEntryName.Substring(frameworkEndIndex + 1); + return !string.IsNullOrEmpty(relativePath); + } + + /// + /// Gets the target framework represented by the current PowerShell host. + /// + private static NuGetFramework GetCurrentFramework() + { + string runtimeDescription = RuntimeInformation.FrameworkDescription ?? string.Empty; + + if (runtimeDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase)) + { + return NuGetFramework.ParseFolder("net472"); + } + + string versionPart = runtimeDescription; + if (versionPart.StartsWith(".NET Core ", StringComparison.OrdinalIgnoreCase)) + { + versionPart = versionPart.Substring(".NET Core ".Length); + } + else if (versionPart.StartsWith(".NET ", StringComparison.OrdinalIgnoreCase)) + { + versionPart = versionPart.Substring(".NET ".Length); + } + + if (Version.TryParse(versionPart, out Version runtimeVersion)) + { + return new NuGetFramework( + ".NETCoreApp", + new Version(runtimeVersion.Major, runtimeVersion.Minor)); + } + + return NuGetFramework.ParseFolder("netstandard2.0"); + } + + private sealed class LibAssetCandidate + { + public LibAssetCandidate(string entryName, NuGetFramework framework) + { + EntryName = entryName; + Framework = framework; + } + + public string EntryName { get; } + + public NuGetFramework Framework { get; } + } + /// /// Moves package files/directories from the temp install path into the final install path location. /// - private bool TryMoveInstallContent(string tempInstallPath, ScopeType scope, ConcurrentDictionary packagesHash) + private bool TryMoveInstallContent( + string tempInstallPath, + ScopeType scope, + ConcurrentDictionary packagesHash, + bool mergeTargetFrameworkContent) { _cmdletPassedIn.WriteDebug("In InstallHelper::TryMoveInstallContent()"); foreach (string pkgName in packagesHash.Keys) @@ -1373,7 +1633,8 @@ private bool TryMoveInstallContent(string tempInstallPath, ScopeType scope, Conc installPath, newVersion: pkgVersion, // would not have prerelease label in this string moduleManifestVersion: pkgVersion, - scriptPath); + scriptPath, + mergeTargetFrameworkContent); _cmdletPassedIn.WriteVerbose($"Successfully installed package '{pkgName}' to location '{installPath}'"); diff --git a/src/code/InstallPSResource.cs b/src/code/InstallPSResource.cs index 7beb61a56..6370377fc 100644 --- a/src/code/InstallPSResource.cs +++ b/src/code/InstallPSResource.cs @@ -138,6 +138,15 @@ public string TemporaryPath [Parameter] public SwitchParameter AuthenticodeCheck { get; set; } + /// + /// Specifies the Target Framework Moniker (TFM) used to select compatible assets from lib/ folders. + /// When omitted, the current PowerShell host framework is used. + /// + [Parameter] + [ValidateNotNullOrEmpty] + [ArgumentCompleter(typeof(TargetFrameworkCompleter))] + public string TargetFramework { get; set; } + /// /// Passes the resource installed to the console. /// @@ -597,7 +606,9 @@ private void ProcessInstallHelper(string[] pkgNames, string pkgVersion, bool pkg pathsToInstallPkg: _pathsToInstallPkg, scope: scope, tmpPath: _tmpPath, - pkgsInstalled: _packagesOnMachine); + pkgsInstalled: _packagesOnMachine, + filterByTargetFramework: true, + targetFramework: TargetFramework); if (PassThru) { diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 3e05f1089..a1fc10310 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1947,6 +1947,32 @@ public static void MoveDirectory( DeleteDirectory(sourceDirPath); } + /// + /// Adds missing files and directories from a source directory without overwriting destination content. + /// + public static void MergeDirContents(string sourceDirPath, string destDirPath) + { + if (!Directory.Exists(destDirPath)) + { + Directory.CreateDirectory(destDirPath); + } + + foreach (string filePath in Directory.GetFiles(sourceDirPath)) + { + string destFilePath = Path.Combine(destDirPath, Path.GetFileName(filePath)); + if (!File.Exists(destFilePath)) + { + File.Copy(filePath, destFilePath); + } + } + + foreach (string sourceSubDirPath in Directory.GetDirectories(sourceDirPath)) + { + string destSubDirPath = Path.Combine(destDirPath, Path.GetFileName(sourceSubDirPath)); + MergeDirContents(sourceSubDirPath, destSubDirPath); + } + } + private static void CopyDirContents( string sourceDirPath, string destDirPath, diff --git a/test/InstallPSResourceTests/InstallPSResourceTargetFramework.Tests.ps1 b/test/InstallPSResourceTests/InstallPSResourceTargetFramework.Tests.ps1 new file mode 100644 index 000000000..ceb7bedea --- /dev/null +++ b/test/InstallPSResourceTests/InstallPSResourceTargetFramework.Tests.ps1 @@ -0,0 +1,358 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] +Param() + +$ProgressPreference = 'SilentlyContinue' +$modPath = "$PSScriptRoot/../PSGetTestUtils.psm1" +Import-Module $modPath -Force + +Describe 'Install-PSResource target framework filtering' -Tags 'CI' { + BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $outModulePath = Join-Path $PSScriptRoot '../../out/Microsoft.PowerShell.PSResourceGet' + Import-Module $outModulePath -Force + + function New-TfmTestNupkg { + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [string] $Version, + + [Parameter(Mandatory)] + [string] $OutputDirectory, + + [Parameter(Mandatory)] + [hashtable] $LibAssets + ) + + $packageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + $null = New-Item -Path $packageRoot -ItemType Directory -Force + + try { + foreach ($targetFramework in $LibAssets.Keys) { + foreach ($relativeAssetPath in $LibAssets[$targetFramework].Keys) { + $assetPath = Join-Path $packageRoot "lib/$targetFramework/$relativeAssetPath" + $null = New-Item -Path (Split-Path $assetPath -Parent) -ItemType Directory -Force + Set-Content -Path $assetPath -Value $LibAssets[$targetFramework][$relativeAssetPath] + } + } + + $moduleManifest = @" +@{ + ModuleVersion = '$Version' + GUID = '$([Guid]::NewGuid())' + Author = 'TestAuthor' + Description = 'Target framework filtering test module' + FunctionsToExport = @() + CmdletsToExport = @() +} +"@ + Set-Content -Path (Join-Path $packageRoot "$Name.psd1") -Value $moduleManifest + Set-Content -Path (Join-Path $packageRoot 'DoNotOverwrite.txt') -Value 'package-original' + + $nuspec = @" + + + + $Name + $Version + TestAuthor + Target framework filtering test package + PSModule test + + +"@ + Set-Content -Path (Join-Path $packageRoot "$Name.nuspec") -Value $nuspec + + $nupkgPath = Join-Path $OutputDirectory "$Name.$Version.nupkg" + if (Test-Path $nupkgPath) { + Remove-Item $nupkgPath -Force + } + + [System.IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $nupkgPath) + return $nupkgPath + } + finally { + Remove-Item $packageRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + function Get-VersionInstallPath { + param( + [Parameter(Mandatory)] + [object] $Package + ) + + $namePath = Join-Path $Package.InstalledLocation $Package.Name + $versionPath = Join-Path $namePath $Package.Version.ToString() + if (Test-Path $versionPath) { + return $versionPath + } + + return $Package.InstalledLocation + } + + function Get-InstalledAsset { + param( + [Parameter(Mandatory)] + [string] $InstallPath, + + [Parameter(Mandatory)] + [string] $AssetName + ) + + return @(Get-ChildItem -Path (Join-Path $InstallPath 'lib') -Recurse -File -Filter $AssetName -ErrorAction SilentlyContinue) + } + + $script:repositoryPath = Join-Path $TestDrive 'TfmAssetRepository' + $null = New-Item -Path $script:repositoryPath -ItemType Directory -Force + $script:repositoryName = "TfmAssetRepository$PID" + Register-PSResourceRepository -Name $script:repositoryName -Uri $script:repositoryPath -Trusted -Force + + $script:packageVersion = '1.0.0' + $script:packageNames = @( + 'TfmAutomaticSelection', + 'TfmExplicitSelection', + 'TfmAzAccountsShape', + 'TfmMergeSelection', + 'TfmNoCompatibleAssets' + ) + + $null = New-TfmTestNupkg ` + -Name 'TfmAutomaticSelection' ` + -Version $script:packageVersion ` + -OutputDirectory $script:repositoryPath ` + -LibAssets @{ + net472 = @{ + 'Shared.dll' = 'net472-shared' + 'FrameworkOnly.dll' = 'net472-only' + } + 'netstandard2.0' = @{ + 'Shared.dll' = 'netstandard-shared' + 'PortableOnly.dll' = 'netstandard-only' + } + 'net8.0' = @{ + 'Shared.dll' = 'net8-shared' + 'CoreOnly.dll' = 'net8-only' + } + } + + $null = New-TfmTestNupkg ` + -Name 'TfmExplicitSelection' ` + -Version $script:packageVersion ` + -OutputDirectory $script:repositoryPath ` + -LibAssets @{ + 'netstandard2.0' = @{ + 'Shared.dll' = 'netstandard-shared' + 'PortableOnly.dll' = 'netstandard-only' + } + 'net6.0' = @{ + 'Shared.dll' = 'net6-shared' + 'Net6Only.dll' = 'net6-only' + } + 'net8.0' = @{ + 'Shared.dll' = 'net8-shared' + 'Net8Only.dll' = 'net8-only' + } + } + + $null = New-TfmTestNupkg ` + -Name 'TfmAzAccountsShape' ` + -Version $script:packageVersion ` + -OutputDirectory $script:repositoryPath ` + -LibAssets @{ + net45 = @{ + 'Common.dll' = 'net45-common' + 'Microsoft.Azure.PowerShell.Authentication.dll' = 'net45-unique' + } + net47 = @{ + 'Common.dll' = 'net47-common' + 'Microsoft.Identity.Client.dll' = 'net47-unique' + } + 'netstandard2.0' = @{ + 'Azure.Core.dll' = 'netstandard-unique' + } + } + + $null = New-TfmTestNupkg ` + -Name 'TfmMergeSelection' ` + -Version $script:packageVersion ` + -OutputDirectory $script:repositoryPath ` + -LibAssets @{ + net472 = @{ + 'Shared.dll' = 'net472-shared' + } + 'net8.0' = @{ + 'Shared.dll' = 'net8-shared' + } + } + + $null = New-TfmTestNupkg ` + -Name 'TfmNoCompatibleAssets' ` + -Version $script:packageVersion ` + -OutputDirectory $script:repositoryPath ` + -LibAssets @{ + 'net8.0' = @{ + 'Net8Only.dll' = 'net8-only' + } + } + } + + AfterEach { + foreach ($packageName in $script:packageNames) { + Uninstall-PSResource ` + -Name $packageName ` + -Version '*' ` + -SkipDependencyCheck ` + -WarningAction SilentlyContinue ` + -ErrorAction SilentlyContinue + } + } + + AfterAll { + Unregister-PSResourceRepository -Name $script:repositoryName -ErrorAction SilentlyContinue + } + + It 'exposes only the TargetFramework platform-filtering parameter' { + $command = Get-Command Install-PSResource + + $command.Parameters.ContainsKey('TargetFramework') | Should -Be $true + $command.Parameters.ContainsKey('RuntimeIdentifier') | Should -Be $false + } + + It 'completes known target framework values' { + $commandLine = 'Install-PSResource -TargetFramework netst' + $completion = TabExpansion2 $commandLine $commandLine.Length + + $completion.CompletionMatches.CompletionText | Should -Contain 'netstandard2.0' + } + + It 'automatically selects the nearest duplicate asset while retaining unique compatible assets' { + Install-PSResource ` + -Name 'TfmAutomaticSelection' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository + + $package = Get-InstalledPSResource -Name 'TfmAutomaticSelection' + $installPath = Get-VersionInstallPath $package + $sharedAssets = Get-InstalledAsset -InstallPath $installPath -AssetName 'Shared.dll' + + $sharedAssets.Count | Should -Be 1 + if ($PSVersionTable.PSVersion.Major -eq 5) { + $sharedAssets[0].Directory.Name | Should -Be 'net472' + (Get-InstalledAsset -InstallPath $installPath -AssetName 'FrameworkOnly.dll').Count | Should -Be 1 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'CoreOnly.dll').Count | Should -Be 0 + } + else { + $sharedAssets[0].Directory.Name | Should -Be 'net8.0' + (Get-InstalledAsset -InstallPath $installPath -AssetName 'FrameworkOnly.dll').Count | Should -Be 0 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'CoreOnly.dll').Count | Should -Be 1 + } + + (Get-InstalledAsset -InstallPath $installPath -AssetName 'PortableOnly.dll').Count | Should -Be 1 + } + + It 'uses the explicit target framework for nearest-compatible asset selection' { + Install-PSResource ` + -Name 'TfmExplicitSelection' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'net6.0' + + $package = Get-InstalledPSResource -Name 'TfmExplicitSelection' + $installPath = Get-VersionInstallPath $package + $sharedAssets = Get-InstalledAsset -InstallPath $installPath -AssetName 'Shared.dll' + + $sharedAssets.Count | Should -Be 1 + $sharedAssets[0].Directory.Name | Should -Be 'net6.0' + (Get-InstalledAsset -InstallPath $installPath -AssetName 'PortableOnly.dll').Count | Should -Be 1 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Net6Only.dll').Count | Should -Be 1 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Net8Only.dll').Count | Should -Be 0 + } + + It 'retains Az.Accounts-shaped unique compatible assets while filtering duplicate variants' { + Install-PSResource ` + -Name 'TfmAzAccountsShape' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'net472' + + $package = Get-InstalledPSResource -Name 'TfmAzAccountsShape' + $installPath = Get-VersionInstallPath $package + $commonAssets = Get-InstalledAsset -InstallPath $installPath -AssetName 'Common.dll' + + $commonAssets.Count | Should -Be 1 + $commonAssets[0].Directory.Name | Should -Be 'net47' + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Microsoft.Azure.PowerShell.Authentication.dll').Count | Should -Be 1 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Microsoft.Identity.Client.dll').Count | Should -Be 1 + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Azure.Core.dll').Count | Should -Be 1 + } + + It 'merges an explicit target into an installed version without overwriting existing files' { + Install-PSResource ` + -Name 'TfmMergeSelection' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'net8.0' + + $package = Get-InstalledPSResource -Name 'TfmMergeSelection' + $installPath = Get-VersionInstallPath $package + Set-Content -Path (Join-Path $installPath 'DoNotOverwrite.txt') -Value 'locally-modified' + + Install-PSResource ` + -Name 'TfmMergeSelection' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'net472' + + Test-Path (Join-Path $installPath 'lib/net8.0/Shared.dll') | Should -Be $true + Test-Path (Join-Path $installPath 'lib/net472/Shared.dll') | Should -Be $true + (Get-Content -Path (Join-Path $installPath 'DoNotOverwrite.txt') -Raw).Trim() | Should -Be 'locally-modified' + } + + It 'warns when an explicit target has no compatible lib assets' { + $warnings = $null + Install-PSResource ` + -Name 'TfmNoCompatibleAssets' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'net472' ` + -WarningVariable warnings + + ($warnings | Out-String) | Should -BeLike '*No compatible lib assets*net472*' + $package = Get-InstalledPSResource -Name 'TfmNoCompatibleAssets' + $installPath = Get-VersionInstallPath $package + (Get-InstalledAsset -InstallPath $installPath -AssetName 'Net8Only.dll').Count | Should -Be 0 + } + + It 'rejects an invalid explicit target framework' { + $caughtError = $null + try { + Install-PSResource ` + -Name 'TfmExplicitSelection' ` + -Version $script:packageVersion ` + -Repository $script:repositoryName ` + -TrustRepository ` + -TargetFramework 'not-a-target-framework' ` + -ErrorAction Stop + } + catch { + $caughtError = $_ + } + + $caughtError | Should -Not -BeNullOrEmpty + $caughtError.FullyQualifiedErrorId | Should -BeLike 'InvalidTargetFramework*' + } +}