diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets index 948f6992a0bd..9d8133e69ece 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets @@ -159,7 +159,8 @@ Copyright (c) .NET Foundation. All rights reserved. --> + TargetPropsFilePath="$(_GeneratedStaticWebAssetsPropsFile)" + FrameworkPattern="$(StaticWebAssetFrameworkPattern)" /> + + diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs index 78966129d04f..935b34bdf86a 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs @@ -556,7 +556,7 @@ internal static string ComputeIntegrity(FileInfo fileInfo) public string ComputeTargetPath(string pathPrefix, char separator) => CombineNormalizedPaths( pathPrefix, - IsDiscovered() || IsComputed() ? "" : BasePath, + IsDiscovered() || IsComputed() || IsFramework() ? "" : BasePath, RelativePath, separator); public static string CombineNormalizedPaths(string prefix, string basePath, string route, char separator) @@ -590,6 +590,7 @@ public void Validate() case SourceTypes.Computed: case SourceTypes.Project: case SourceTypes.Package: + case SourceTypes.Framework: break; default: throw new InvalidOperationException($"Unknown source type '{SourceType}' for '{Identity}'."); @@ -772,6 +773,9 @@ public bool IsProject() public bool IsPackage() => string.Equals(SourceType, SourceTypes.Package, StringComparison.Ordinal); + public bool IsFramework() + => string.Equals(SourceType, SourceTypes.Framework, StringComparison.Ordinal); + public bool IsBuildOnly() => string.Equals(AssetKind, AssetKinds.Build, StringComparison.Ordinal); @@ -1017,8 +1021,10 @@ public static class SourceTypes public const string Computed = nameof(Computed); public const string Project = nameof(Project); public const string Package = nameof(Package); + public const string Framework = nameof(Framework); public static bool IsPackage(string sourceType) => string.Equals(Package, sourceType, StringComparison.Ordinal); + public static bool IsFramework(string sourceType) => string.Equals(Framework, sourceType, StringComparison.Ordinal); } public static class AssetCopyOptions @@ -1060,7 +1066,7 @@ private string CreatePathString(string pathPrefix, char separator) { var prefix = pathPrefix != null ? Normalize(pathPrefix) : ""; // These have been normalized already, so only contain forward slashes - var computedBasePath = IsDiscovered() || IsComputed() ? "" : BasePath; + var computedBasePath = IsDiscovered() || IsComputed() || IsFramework() ? "" : BasePath; if (computedBasePath == "/") { // We need to special case the base path "/" to make sure it gets correctly combined with the prefix diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs index eee823f19e26..2ff0ff6759f3 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs @@ -40,6 +40,8 @@ public class GenerateStaticWebAssetsPropsFile : Task public bool AllowEmptySourceType { get; set; } + public string FrameworkPattern { get; set; } + public override bool Execute() { if (!ValidateArguments()) @@ -59,6 +61,21 @@ private bool ExecuteCore() var tokenResolver = StaticWebAssetTokenResolver.Instance; + StaticWebAssetGlobMatcher frameworkMatcher = null; + if (!string.IsNullOrEmpty(FrameworkPattern)) + { + var patterns = FrameworkPattern + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToArray(); + frameworkMatcher = new StaticWebAssetGlobMatcherBuilder() + .AddIncludePatterns(patterns) + .Build(); + } + + var hasFrameworkMatcher = frameworkMatcher != null; + var matchContext = hasFrameworkMatcher ? StaticWebAssetGlobMatcher.CreateMatchContext() : default; + var itemGroup = new XElement("ItemGroup"); var orderedAssets = StaticWebAssets.OrderBy(e => e.GetMetadata(BasePath), StringComparer.OrdinalIgnoreCase) .ThenBy(e => e.GetMetadata(RelativePath), StringComparer.OrdinalIgnoreCase); @@ -68,9 +85,26 @@ private bool ExecuteCore() var packagePath = asset.ComputeTargetPath(PackagePathPrefix, '\\', tokenResolver); var relativePath = asset.ReplaceTokens(asset.RelativePath, tokenResolver); var fullPathExpression = @$"$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\{packagePath}'))"; + + var emittedSourceType = "Package"; + if (hasFrameworkMatcher) + { + matchContext.SetPathAndReinitialize(relativePath.AsSpan()); + var match = frameworkMatcher.Match(matchContext); + if (match.IsMatch) + { + emittedSourceType = "Framework"; + Log.LogMessage(MessageImportance.Low, "Asset '{0}' with relative path '{1}' matched framework pattern. Emitting as Framework.", element.ItemSpec, relativePath); + } + else + { + Log.LogMessage(MessageImportance.Low, "Asset '{0}' with relative path '{1}' did not match framework pattern. Emitting as Package.", element.ItemSpec, relativePath); + } + } + itemGroup.Add(new XElement("StaticWebAsset", new XAttribute("Include", fullPathExpression), - new XElement(SourceType, "Package"), + new XElement(SourceType, emittedSourceType), new XElement(SourceId, element.GetMetadata(SourceId)), new XElement(ContentRoot, @$"$(MSBuildThisFileDirectory)..\{Normalize(PackagePathPrefix)}\"), new XElement(BasePath, element.GetMetadata(BasePath)), diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index 9ec894c31853..eb4f727d79a6 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -3,7 +3,9 @@ #nullable disable +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -12,32 +14,60 @@ public class UpdatePackageStaticWebAssets : Task [Required] public ITaskItem[] Assets { get; set; } + public string IntermediateOutputPath { get; set; } + + public string ProjectPackageId { get; set; } + + public string ProjectBasePath { get; set; } + [Output] public ITaskItem[] UpdatedAssets { get; set; } [Output] public ITaskItem[] OriginalAssets { get; set; } + [Output] + public ITaskItem[] RemappedEndpoints { get; set; } + + public ITaskItem[] Endpoints { get; set; } + public override bool Execute() { try { var originalAssets = new List(); var updatedAssets = new List(); + var assetMapping = new Dictionary(OSPath.PathComparer); + for (var i = 0; i < Assets.Length; i++) { var candidate = Assets[i]; - if (!StaticWebAsset.SourceTypes.IsPackage(candidate.GetMetadata(nameof(StaticWebAsset.SourceType)))) + var sourceType = candidate.GetMetadata(nameof(StaticWebAsset.SourceType)); + + if (StaticWebAsset.SourceTypes.IsPackage(sourceType)) { - continue; + originalAssets.Add(candidate); + updatedAssets.Add(StaticWebAsset.FromV1TaskItem(candidate).ToTaskItem()); + } + else if (StaticWebAsset.SourceTypes.IsFramework(sourceType)) + { + originalAssets.Add(candidate); + var (transformed, oldPath) = MaterializeFrameworkAsset(candidate); + if (transformed != null) + { + updatedAssets.Add(transformed.ToTaskItem()); + assetMapping[oldPath] = transformed.Identity; + } } - - originalAssets.Add(candidate); - updatedAssets.Add(StaticWebAsset.FromV1TaskItem(candidate).ToTaskItem()); } OriginalAssets = [.. originalAssets]; UpdatedAssets = [.. updatedAssets]; + + if (Endpoints != null && assetMapping.Count > 0) + { + RemapEndpoints(assetMapping); + } } catch (Exception ex) { @@ -46,4 +76,100 @@ public override bool Execute() return !Log.HasLoggedErrors; } + + private void RemapEndpoints(Dictionary assetMapping) + { + var remappedEndpoints = new List(); + + var endpointsByIdentity = new Dictionary>(StringComparer.Ordinal); + foreach (var endpoint in Endpoints) + { + var identity = endpoint.ItemSpec; + if (!endpointsByIdentity.TryGetValue(identity, out var group)) + { + group = new List(); + endpointsByIdentity[identity] = group; + } + group.Add(endpoint); + } + + foreach (var kvp in endpointsByIdentity) + { + var identity = kvp.Key; + var group = kvp.Value; + var groupNeedsRemapping = false; + foreach (var endpoint in group) + { + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.ContainsKey(assetFile)) + { + groupNeedsRemapping = true; + break; + } + } + + if (groupNeedsRemapping) + { + foreach (var endpoint in group) + { + var newEndpoint = new TaskItem(endpoint); + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + { + newEndpoint.SetMetadata("AssetFile", newAssetFile); + Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", + identity, assetFile, newAssetFile); + } + remappedEndpoints.Add(newEndpoint); + } + } + } + + RemappedEndpoints = [.. remappedEndpoints]; + } + + private (StaticWebAsset, string) MaterializeFrameworkAsset(ITaskItem candidate) + { + var asset = StaticWebAsset.FromV1TaskItem(candidate); + + var originalSourceId = asset.SourceId; + var relativePath = asset.RelativePath; + var oldIdentity = asset.Identity; + + var fxDir = Path.Combine(IntermediateOutputPath, "fx", originalSourceId); + var destPath = Path.Combine(fxDir, StaticWebAsset.Normalize(relativePath)); + destPath = Path.GetFullPath(destPath); + + var sourceFile = asset.Identity; + if (!File.Exists(sourceFile)) + { + Log.LogError("Source file '{0}' does not exist for framework asset materialization.", sourceFile); + return (null, null); + } + + var destDir = Path.GetDirectoryName(destPath); + Directory.CreateDirectory(destDir); + + if (!File.Exists(destPath) || File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destPath)) + { + File.Copy(sourceFile, destPath, overwrite: true); + Log.LogMessage(MessageImportance.Low, "Materialized framework asset '{0}' to '{1}'.", sourceFile, destPath); + } + else + { + Log.LogMessage(MessageImportance.Low, "Framework asset '{0}' already up to date at '{1}'.", sourceFile, destPath); + } + + asset.Identity = destPath; + asset.OriginalItemSpec = destPath; + asset.ContentRoot = StaticWebAsset.NormalizeContentRootPath(fxDir); + asset.SourceType = StaticWebAsset.SourceTypes.Discovered; + asset.SourceId = ProjectPackageId; + asset.BasePath = ProjectBasePath; + asset.AssetMode = StaticWebAsset.AssetModes.CurrentProject; + asset.Normalize(); + + return (asset, oldIdentity); + } + } diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs new file mode 100644 index 000000000000..de37f48a78fa --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.IO.Compression; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests +{ + public class FrameworkAssetsIntegrationTest(ITestOutputHelper log) + : IsolatedNuGetPackageFolderAspNetSdkBaselineTest(log, nameof(FrameworkAssetsIntegrationTest)) + { + [Fact] + public void Pack_PropsFile_ContainsFrameworkSourceType_ForMatchedAssets() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + ExecuteCommand(pack).Should().Pass(); + + var packagePath = Path.Combine( + ProjectDirectory.TestRoot, + "TestPackages", + "FrameworkAssetsLib.1.0.0.nupkg"); + + new FileInfo(packagePath).Should().Exist(); + + // Extract the props file from the nupkg and verify SourceType + using var archive = ZipFile.OpenRead(packagePath); + var propsEntry = archive.Entries.FirstOrDefault( + e => e.FullName.Equals("build/Microsoft.AspNetCore.StaticWebAssets.props", StringComparison.OrdinalIgnoreCase)); + + propsEntry.Should().NotBeNull("the nupkg should contain a StaticWebAssets.props file"); + + using var stream = propsEntry.Open(); + using var reader = new StreamReader(stream); + var propsContent = reader.ReadToEnd(); + + // JS files should be marked as Framework + propsContent.Should().Contain("Framework", + "JS assets matching the FrameworkPattern should have SourceType=Framework"); + + // CSS files should remain as Package + propsContent.Should().Contain("Package", + "CSS assets not matching the FrameworkPattern should have SourceType=Package"); + } + + [Fact] + public void Pack_NupkgContains_ExpectedStaticWebAssets() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + var result = ExecuteCommand(pack); + + result.Should().Pass(); + + var packagePath = Path.Combine( + ProjectDirectory.TestRoot, + "TestPackages", + "FrameworkAssetsLib.1.0.0.nupkg"); + + result.Should().NuPkgContainsPatterns( + packagePath, + filePatterns: new[] + { + Path.Combine("staticwebassets", "js", "framework.js"), + Path.Combine("staticwebassets", "css", "site.css"), + Path.Combine("build", "Microsoft.AspNetCore.StaticWebAssets.props"), + Path.Combine("build", "FrameworkAssetsLib.props"), + Path.Combine("buildMultiTargeting", "FrameworkAssetsLib.props"), + Path.Combine("buildTransitive", "FrameworkAssetsLib.props"), + }); + } + + [Fact] + public void Build_Consumer_MaterializesFrameworkAssets() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + // Pack the library first + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + ExecuteCommand(pack).Should().Pass(); + + // Restore and build the consumer + var restore = CreateRestoreCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(restore).Should().Pass(); + + var build = CreateBuildCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(build).Should().Pass(); + + var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + + // Verify the build manifest exists and contains our framework asset + var manifestPath = Path.Combine(intermediateOutputPath, "staticwebassets.build.json"); + new FileInfo(manifestPath).Should().Exist(); + + var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(manifestPath)); + manifest.Should().NotBeNull(); + + // The framework JS asset should be materialized (SourceType changed from Framework to Discovered) + var frameworkAssets = manifest.Assets + .Where(a => a.RelativePath.Contains("framework.js")) + .ToList(); + + frameworkAssets.Should().NotBeEmpty("framework.js should appear in the build manifest"); + + // After materialization, the framework asset should have SourceType=Discovered + // and be under the fx/ intermediate directory + var materializedAsset = frameworkAssets + .FirstOrDefault(a => a.Identity.Contains(Path.Combine("fx", "FrameworkAssetsLib"))); + + materializedAsset.Should().NotBeNull( + "framework.js should be materialized under the fx/FrameworkAssetsLib directory"); + materializedAsset.SourceType.Should().Be("Discovered"); + materializedAsset.AssetMode.Should().Be("CurrentProject"); + + // The CSS asset should remain as a regular Package asset (not materialized) + var cssAssets = manifest.Assets + .Where(a => a.RelativePath.Contains("site.css")) + .ToList(); + + cssAssets.Should().NotBeEmpty("site.css should appear in the build manifest"); + cssAssets.Should().OnlyContain(a => a.SourceType == "Package", + "CSS assets should remain as Package type since they don't match the FrameworkPattern"); + } + + [Fact] + public void Build_Consumer_MaterializedFrameworkAsset_FileExistsOnDisk() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + // Pack the library first + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + ExecuteCommand(pack).Should().Pass(); + + // Restore and build the consumer + var restore = CreateRestoreCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(restore).Should().Pass(); + + var build = CreateBuildCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(build).Should().Pass(); + + var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + + // The materialized file should exist on disk under the staticwebassets/fx directory + var fxDir = Path.Combine(intermediateOutputPath, "staticwebassets", "fx", "FrameworkAssetsLib"); + var materializedFile = Directory.GetFiles(fxDir, "framework.js", SearchOption.AllDirectories); + + materializedFile.Should().HaveCount(1, "framework.js should be materialized exactly once"); + File.ReadAllText(materializedFile[0]).Should().NotBeEmpty(); + } + + [Fact] + public void Build_Consumer_EndpointsRemapped_ForFrameworkAssets() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + // Pack the library first + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + ExecuteCommand(pack).Should().Pass(); + + // Restore and build the consumer + var restore = CreateRestoreCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(restore).Should().Pass(); + + var build = CreateBuildCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(build).Should().Pass(); + + var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + + // Read the build manifest to check endpoints + var manifestPath = Path.Combine(intermediateOutputPath, "staticwebassets.build.json"); + var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(manifestPath)); + + // Check that the framework asset in the manifest has been remapped to the materialized path + var frameworkAssets = manifest.Assets + .Where(a => a.RelativePath.Contains("framework.js") + && a.Identity.Contains(Path.Combine("staticwebassets", "fx", "FrameworkAssetsLib"))) + .ToList(); + + frameworkAssets.Should().NotBeEmpty( + "the manifest should contain a materialized framework asset under staticwebassets/fx/"); + + // Endpoints for the route should exist (some may be compressed variants) + var fxEndpoints = manifest.Endpoints + ?.Where(e => e.Route.Contains("framework.js")) + .ToList(); + + fxEndpoints.Should().NotBeNull().And.NotBeEmpty( + "there should be at least one endpoint for framework.js"); + + // At least one endpoint should reference the materialized asset (not all will — compressed endpoints point elsewhere) + var endpointsPointingToMaterialized = fxEndpoints + .Where(e => e.AssetFile.Contains(Path.Combine("staticwebassets", "fx", "FrameworkAssetsLib"))) + .ToList(); + + endpointsPointingToMaterialized.Should().NotBeEmpty( + "at least one endpoint for framework.js should point to the materialized file path under staticwebassets/fx/"); + } + + [Fact] + public void Build_Consumer_IsIncremental() + { + var testAsset = "FrameworkAssetsSample"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + // Pack the library first + var pack = CreatePackCommand(ProjectDirectory, "FrameworkAssetsLib"); + ExecuteCommand(pack).Should().Pass(); + + // Restore once + var restore = CreateRestoreCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(restore).Should().Pass(); + + // First build + var build1 = CreateBuildCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(build1).Should().Pass(); + + var intermediateOutputPath = build1.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + var fxDir = Path.Combine(intermediateOutputPath, "staticwebassets", "fx", "FrameworkAssetsLib"); + var materializedFile = Directory.GetFiles(fxDir, "framework.js", SearchOption.AllDirectories).Single(); + var firstWriteTime = File.GetLastWriteTimeUtc(materializedFile); + + // Second build — should be incremental (file not re-copied) + var build2 = CreateBuildCommand(ProjectDirectory, "FrameworkAssetsConsumer"); + ExecuteCommand(build2).Should().Pass(); + + var secondWriteTime = File.GetLastWriteTimeUtc(materializedFile); + secondWriteTime.Should().Be(firstWriteTime, + "framework asset should not be re-copied on incremental build"); + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsPropsFileTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsPropsFileTest.cs index 4e083a3d64b7..ccbcefc18867 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsPropsFileTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/GenerateStaticWebAssetsPropsFileTest.cs @@ -510,6 +510,354 @@ public void WritesIndividualItems_WithTheirRespectiveBaseAndRelativePaths() } } + [Fact] + public void WritesFrameworkSourceType_WhenAssetMatchesFrameworkPattern() + { + // Arrange + var file = Path.GetTempFileName(); + var expectedDocument = @" + + + Package + MyLibrary + $(MSBuildThisFileDirectory)..\staticwebassets\ + _content/mylibrary + css/site.css + All + All + Primary + + + + css-fingerprint + css-integrity + Never + PreserveNewest + 10 + Thu, 15 Nov 1990 00:00:00 GMT + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\staticwebassets\css\site.css')) + + + Framework + MyLibrary + $(MSBuildThisFileDirectory)..\staticwebassets\ + _content/mylibrary + js/framework.js + All + All + Primary + + + + js-fingerprint + js-integrity + Never + PreserveNewest + 10 + Thu, 15 Nov 1990 00:00:00 GMT + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\staticwebassets\js\framework.js')) + + +"; + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsPropsFile + { + BuildEngine = buildEngine.Object, + TargetPropsFilePath = file, + FrameworkPattern = "**/*.js", + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","js","framework.js"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "js/framework.js", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "js-fingerprint", + ["Integrity"] = "js-integrity", + ["OriginalItemSpec"] = Path.Combine("wwwroot","js","framework.js"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + CreateItem(Path.Combine("wwwroot","css","site.css"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "css/site.css", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "css-fingerprint", + ["Integrity"] = "css-integrity", + ["OriginalItemSpec"] = Path.Combine("wwwroot","css","site.css"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var document = File.ReadAllText(file); + Assert.Equal(expectedDocument, document, ignoreLineEndingDifferences: true); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + [Fact] + public void WritesAllAsPackage_WhenFrameworkPatternIsNull() + { + // Arrange + var file = Path.GetTempFileName(); + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsPropsFile + { + BuildEngine = buildEngine.Object, + TargetPropsFilePath = file, + FrameworkPattern = null, + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","js","app.js"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "js/app.js", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "fp", + ["Integrity"] = "int", + ["OriginalItemSpec"] = Path.Combine("wwwroot","js","app.js"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + } + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var document = File.ReadAllText(file); + document.Should().Contain("Package"); + document.Should().NotContain("Framework"); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + [Fact] + public void WritesFrameworkSourceType_WithMultiplePatterns() + { + // Arrange + var file = Path.GetTempFileName(); + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsPropsFile + { + BuildEngine = buildEngine.Object, + TargetPropsFilePath = file, + FrameworkPattern = "**/*.js;**/*.css", + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","js","app.js"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "js/app.js", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "fp1", + ["Integrity"] = "int1", + ["OriginalItemSpec"] = Path.Combine("wwwroot","js","app.js"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + CreateItem(Path.Combine("wwwroot","css","site.css"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "css/site.css", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "fp2", + ["Integrity"] = "int2", + ["OriginalItemSpec"] = Path.Combine("wwwroot","css","site.css"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + CreateItem(Path.Combine("wwwroot","images","logo.png"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "images/logo.png", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "fp3", + ["Integrity"] = "int3", + ["OriginalItemSpec"] = Path.Combine("wwwroot","images","logo.png"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + } + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var document = File.ReadAllText(file); + // JS and CSS files should be Framework, PNG should be Package + var lines = document.Split('\n').Select(l => l.Trim()).ToList(); + var sourceTypeLines = lines.Where(l => l.StartsWith("")).ToList(); + // Order is: css/site.css, images/logo.png, js/app.js (sorted by BasePath then RelativePath) + sourceTypeLines.Should().HaveCount(3); + sourceTypeLines[0].Should().Be("Framework"); // css/site.css + sourceTypeLines[1].Should().Be("Package"); // images/logo.png + sourceTypeLines[2].Should().Be("Framework"); // js/app.js + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + + [Fact] + public void WritesAllAsPackage_WhenFrameworkPatternMatchesNothing() + { + // Arrange + var file = Path.GetTempFileName(); + + try + { + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsPropsFile + { + BuildEngine = buildEngine.Object, + TargetPropsFilePath = file, + FrameworkPattern = "**/*.wasm", + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","js","app.js"), new Dictionary + { + ["SourceType"] = "Discovered", + ["SourceId"] = "MyLibrary", + ["ContentRoot"] = @"$(MSBuildThisFileDirectory)..\staticwebassets", + ["BasePath"] = "_content/mylibrary", + ["RelativePath"] = "js/app.js", + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["Fingerprint"] = "fp", + ["Integrity"] = "int", + ["OriginalItemSpec"] = Path.Combine("wwwroot","js","app.js"), + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat) + }), + } + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var document = File.ReadAllText(file); + document.Should().Contain("Package"); + document.Should().NotContain("Framework"); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + private static TaskItem CreateItem( string spec, IDictionary metadata) diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs new file mode 100644 index 000000000000..ad56be557e37 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs @@ -0,0 +1,626 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Moq; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public class UpdatePackageStaticWebAssetsTest : IDisposable +{ + private readonly string _tempDir; + private readonly Mock _buildEngine; + private readonly List _errorMessages; + private readonly List _logMessages; + + public UpdatePackageStaticWebAssetsTest() + { + _tempDir = Path.Combine(Path.GetTempPath(), "UpdatePackageSWA_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + _errorMessages = new List(); + _logMessages = new List(); + _buildEngine = new Mock(); + _buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => _errorMessages.Add(args.Message)); + _buildEngine.Setup(e => e.LogMessageEvent(It.IsAny())) + .Callback(args => _logMessages.Add(args.Message)); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + } + + [Fact] + public void Execute_PackageAssets_ArePassedThrough() + { + // Arrange + var sourceFile = CreateTempFile("pkg", "content.js", "console.log('pkg');"); + var asset = CreatePackageAsset(sourceFile, "MyLib", "_content/mylib", "content.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + task.OriginalAssets.Should().HaveCount(1); + task.UpdatedAssets[0].GetMetadata("SourceType").Should().Be("Package"); + } + + [Fact] + public void Execute_FrameworkAssets_AreMaterialized() + { + // Arrange + var sourceFile = CreateTempFile("source", "js", "framework.js", "console.log('framework');"); + var asset = CreateFrameworkAsset(sourceFile, "FrameworkLib", "_content/frameworklib", "js/framework.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + task.OriginalAssets.Should().HaveCount(1); + + var updated = task.UpdatedAssets[0]; + + // The materialized file should exist in the fx directory + var expectedDir = Path.Combine(intermediateOutput, "fx", "FrameworkLib"); + var expectedPath = Path.GetFullPath(Path.Combine(expectedDir, "js", "framework.js")); + updated.ItemSpec.Should().Be(expectedPath); + File.Exists(expectedPath).Should().BeTrue(); + File.ReadAllText(expectedPath).Should().Be("console.log('framework');"); + } + + [Fact] + public void Execute_FrameworkAssets_SourceTypeChangedToDiscovered() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FrameworkLib", "_content/frameworklib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var updated = task.UpdatedAssets[0]; + updated.GetMetadata("SourceType").Should().Be("Discovered"); + updated.GetMetadata("SourceId").Should().Be("ConsumerApp"); + updated.GetMetadata("BasePath").Should().Be("_content/consumerapp"); + updated.GetMetadata("AssetMode").Should().Be("CurrentProject"); + } + + [Fact] + public void Execute_FrameworkAssets_ContentRootPointsToFxDirectory() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var updated = task.UpdatedAssets[0]; + var expectedContentRoot = Path.Combine(intermediateOutput, "fx", "FxLib") + Path.DirectorySeparatorChar; + updated.GetMetadata("ContentRoot").Should().Be(expectedContentRoot); + } + + [Fact] + public void Execute_FrameworkAssets_MissingSourceFile_LogsError() + { + // Arrange + var nonExistentFile = Path.Combine(_tempDir, "does_not_exist.js"); + var asset = CreateFrameworkAsset(nonExistentFile, "FxLib", "_content/fxlib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeFalse(); + _errorMessages.Should().ContainSingle(e => e.Contains("does not exist") && e.Contains("does_not_exist.js")); + } + + [Fact] + public void Execute_MixedAssets_ProcessesBothTypes() + { + // Arrange + var pkgFile = CreateTempFile("pkg", "package.js", "console.log('pkg');"); + var fxFile = CreateTempFile("source", "framework.js", "console.log('fx');"); + + var pkgAsset = CreatePackageAsset(pkgFile, "MyLib", "_content/mylib", "package.js"); + var fxAsset = CreateFrameworkAsset(fxFile, "MyLib", "_content/mylib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { pkgAsset, fxAsset }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(2); + task.OriginalAssets.Should().HaveCount(2); + + // Package asset stays as Package + task.UpdatedAssets[0].GetMetadata("SourceType").Should().Be("Package"); + // Framework asset is converted to Discovered + task.UpdatedAssets[1].GetMetadata("SourceType").Should().Be("Discovered"); + } + + [Fact] + public void Execute_FrameworkAssets_PreservesOriginalFingerprintAndIntegrity() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + + // Get the fingerprint/integrity that were computed by FromV1TaskItem in CreateFrameworkAsset + var originalFingerprint = asset.GetMetadata("Fingerprint"); + var originalIntegrity = asset.GetMetadata("Integrity"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var updated = task.UpdatedAssets[0]; + // Fingerprint and integrity should be preserved from the original file + updated.GetMetadata("Fingerprint").Should().Be(originalFingerprint); + updated.GetMetadata("Integrity").Should().Be(originalIntegrity); + } + + [Fact] + public void Execute_FrameworkAssets_IncrementalSkipsCopy_WhenUpToDate() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + var fxDir = Path.Combine(intermediateOutput, "fx", "FxLib"); + var destPath = Path.Combine(fxDir, "framework.js"); + + // Pre-create the destination so it's already up-to-date + Directory.CreateDirectory(fxDir); + File.Copy(sourceFile, destPath); + // Make the dest file newer than the source + File.SetLastWriteTimeUtc(destPath, DateTime.UtcNow.AddMinutes(1)); + + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + // Should log the "already up to date" message + _logMessages.Should().Contain(m => m.Contains("already up to date")); + } + + [Fact] + public void Execute_FrameworkAssets_OverwritesStaleDestination() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "new content"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + var fxDir = Path.Combine(intermediateOutput, "fx", "FxLib"); + var destPath = Path.Combine(fxDir, "framework.js"); + + // Pre-create the destination with old content and older timestamp + Directory.CreateDirectory(fxDir); + File.WriteAllText(destPath, "old content"); + File.SetLastWriteTimeUtc(destPath, DateTime.UtcNow.AddMinutes(-10)); + File.SetLastWriteTimeUtc(sourceFile, DateTime.UtcNow); + + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + File.ReadAllText(destPath).Should().Be("new content"); + _logMessages.Should().Contain(m => m.Contains("Materialized framework asset")); + } + + [Fact] + public void Execute_NoFrameworkAssets_EndpointsNotRemapped() + { + // Arrange + var sourceFile = CreateTempFile("pkg", "content.js", "console.log('pkg');"); + var pkgAsset = CreatePackageAsset(sourceFile, "MyLib", "_content/mylib", "content.js"); + + var endpoint = new TaskItem("content.js", new Dictionary + { + ["Route"] = "/content.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { pkgAsset }, + Endpoints = new ITaskItem[] { endpoint }, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + // No framework assets => no remapping done + task.RemappedEndpoints.Should().BeNullOrEmpty(); + } + + [Fact] + public void Execute_FrameworkAssets_EndpointsAreRemapped() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var endpoint = new TaskItem("framework.js", new Dictionary + { + ["Route"] = "/_content/fxlib/framework.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + Endpoints = new ITaskItem[] { endpoint }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.RemappedEndpoints.Should().HaveCount(1); + + var remapped = task.RemappedEndpoints[0]; + var expectedPath = Path.GetFullPath(Path.Combine(intermediateOutput, "fx", "FxLib", "framework.js")); + remapped.GetMetadata("AssetFile").Should().Be(expectedPath); + } + + [Fact] + public void Execute_MultipleEndpoints_SameIdentity_AllRemapped() + { + // Arrange — two endpoints share the same Identity (e.g. same route, different selectors) + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var endpoint1 = new TaskItem("framework.js", new Dictionary + { + ["Route"] = "/_content/fxlib/framework.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[{\"Name\":\"Content-Encoding\",\"Value\":\"gzip\"}]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var endpoint2 = new TaskItem("framework.js", new Dictionary + { + ["Route"] = "/_content/fxlib/framework.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + Endpoints = new ITaskItem[] { endpoint1, endpoint2 }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.RemappedEndpoints.Should().HaveCount(2); + + var expectedPath = Path.GetFullPath(Path.Combine(intermediateOutput, "fx", "FxLib", "framework.js")); + task.RemappedEndpoints[0].GetMetadata("AssetFile").Should().Be(expectedPath); + task.RemappedEndpoints[1].GetMetadata("AssetFile").Should().Be(expectedPath); + } + + [Fact] + public void Execute_EndpointsNotMatchingFramework_AreNotRemapped() + { + // Arrange — endpoint pointing to a file that is NOT a framework asset + var fxFile = CreateTempFile("source", "framework.js", "fx"); + var pkgFile = CreateTempFile("pkg", "package.js", "pkg"); + var fxAsset = CreateFrameworkAsset(fxFile, "FxLib", "_content/fxlib", "framework.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var fxEndpoint = new TaskItem("framework.js", new Dictionary + { + ["Route"] = "/_content/fxlib/framework.js", + ["AssetFile"] = fxFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var pkgEndpoint = new TaskItem("package.js", new Dictionary + { + ["Route"] = "/_content/fxlib/package.js", + ["AssetFile"] = pkgFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = "[]", + }); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { fxAsset }, + Endpoints = new ITaskItem[] { fxEndpoint, pkgEndpoint }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + // Only the framework endpoint should be remapped + task.RemappedEndpoints.Should().HaveCount(1); + task.RemappedEndpoints[0].ItemSpec.Should().Be("framework.js"); + } + + [Fact] + public void Execute_NullEndpoints_DoesNotRemapAndSucceeds() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + Endpoints = null, + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + task.RemappedEndpoints.Should().BeNullOrEmpty(); + } + + [Fact] + public void Execute_EmptyAssetsArray_Succeeds() + { + // Arrange + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = Array.Empty(), + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().BeEmpty(); + task.OriginalAssets.Should().BeEmpty(); + } + + [Fact] + public void Execute_FrameworkAssets_SubdirectoriesArePreserved() + { + // Arrange + var sourceFile = CreateTempFile("source", "lib", "deep", "nested", "component.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/fxlib", "lib/deep/nested/component.js"); + var intermediateOutput = Path.Combine(_tempDir, "obj"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = new[] { asset }, + IntermediateOutputPath = intermediateOutput, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/consumerapp", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + var expectedPath = Path.GetFullPath(Path.Combine(intermediateOutput, "fx", "FxLib", "lib", "deep", "nested", "component.js")); + task.UpdatedAssets[0].ItemSpec.Should().Be(expectedPath); + File.Exists(expectedPath).Should().BeTrue(); + } + + // Helpers + + private string CreateTempFile(params string[] pathParts) + { + // Last part is the content, everything before is path segments + var content = pathParts[^1]; + var segments = pathParts[..^1]; + + var dir = Path.Combine(new[] { _tempDir }.Concat(segments[..^1]).ToArray()); + Directory.CreateDirectory(dir); + var filePath = Path.Combine(dir, segments[^1]); + File.WriteAllText(filePath, content); + return filePath; + } + + private ITaskItem CreatePackageAsset(string filePath, string sourceId, string basePath, string relativePath) + { + var contentRoot = Path.GetDirectoryName(filePath) + Path.DirectorySeparatorChar; + return new TaskItem(filePath, new Dictionary + { + ["SourceType"] = "Package", + ["SourceId"] = sourceId, + ["ContentRoot"] = contentRoot, + ["BasePath"] = basePath, + ["RelativePath"] = relativePath, + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["OriginalItemSpec"] = filePath, + ["Fingerprint"] = "test-fingerprint", + ["Integrity"] = "test-integrity", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat), + }); + } + + private ITaskItem CreateFrameworkAsset(string filePath, string sourceId, string basePath, string relativePath) + { + var contentRoot = Path.GetDirectoryName(filePath) + Path.DirectorySeparatorChar; + return new TaskItem(filePath, new Dictionary + { + ["SourceType"] = "Framework", + ["SourceId"] = sourceId, + ["ContentRoot"] = contentRoot, + ["BasePath"] = basePath, + ["RelativePath"] = relativePath, + ["AssetKind"] = "All", + ["AssetMode"] = "All", + ["AssetRole"] = "Primary", + ["RelatedAsset"] = "", + ["AssetTraitName"] = "", + ["AssetTraitValue"] = "", + ["CopyToOutputDirectory"] = "Never", + ["CopyToPublishDirectory"] = "PreserveNewest", + ["OriginalItemSpec"] = filePath, + ["Fingerprint"] = "test-fingerprint", + ["Integrity"] = "test-integrity", + ["FileLength"] = "10", + ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat), + }); + } +} diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj new file mode 100644 index 000000000000..09be01d91180 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj @@ -0,0 +1,27 @@ + + + + $(AspNetTestTfm) + + + + false + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs new file mode 100644 index 000000000000..d3f78b855c8a --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs @@ -0,0 +1,11 @@ +namespace FrameworkAssetsConsumer +{ + public class Program + { + public static void Main(string[] args) + { + // Minimal program to validate build succeeds with framework package assets + System.Console.WriteLine("FrameworkAssetsConsumer built successfully."); + } + } +} diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj new file mode 100644 index 000000000000..4852822f19c0 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj @@ -0,0 +1,27 @@ + + + + $(AspNetTestTfm) + true + © Microsoft + Framework Assets Test + Microsoft + Library producing framework assets + false + + + **/*.js + + $(AspNetTestPackageSource) + 1.0.0 + + + + false + + + + + + + diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/css/site.css b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/css/site.css new file mode 100644 index 000000000000..90e962f3e833 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/css/site.css @@ -0,0 +1,2 @@ +/* Regular CSS file - should remain as SourceType="Package" when packed */ +body { margin: 0; } diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/js/framework.js b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/js/framework.js new file mode 100644 index 000000000000..783ccdce011f --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/js/framework.js @@ -0,0 +1,2 @@ +// Framework JavaScript file - should be marked as SourceType="Framework" when packed +console.log("framework.js loaded");