From 792894c1c1e84ca2d696702a25a8e8a5113cf5e8 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 12 Feb 2026 15:28:50 +0100 Subject: [PATCH 1/9] Add Framework SourceType for static web assets --- ...osoft.NET.Sdk.StaticWebAssets.Pack.targets | 3 +- .../Microsoft.NET.Sdk.StaticWebAssets.targets | 18 +- .../Tasks/Data/StaticWebAsset.cs | 8 +- .../Tasks/GenerateStaticWebAssetsPropsFile.cs | 40 ++++- .../Tasks/UpdatePackageStaticWebAssets.cs | 154 +++++++++++++++++- 5 files changed, 212 insertions(+), 11 deletions(-) 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..06d024913b70 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 diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs index eee823f19e26..8eaee6c4af04 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs @@ -40,6 +40,10 @@ public class GenerateStaticWebAssetsPropsFile : Task public bool AllowEmptySourceType { get; set; } + // Glob pattern for marking assets as Framework. Assets matching this pattern + // will be emitted with SourceType="Framework" instead of "Package". + public string FrameworkPattern { get; set; } + public override bool Execute() { if (!ValidateArguments()) @@ -59,6 +63,22 @@ private bool ExecuteCore() var tokenResolver = StaticWebAssetTokenResolver.Instance; + // Build a glob matcher for the framework pattern if provided + 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 +88,27 @@ private bool ExecuteCore() var packagePath = asset.ComputeTargetPath(PackagePathPrefix, '\\', tokenResolver); var relativePath = asset.ReplaceTokens(asset.RelativePath, tokenResolver); var fullPathExpression = @$"$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\{packagePath}'))"; + + // Determine SourceType for the emitted item + 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..ad7c7e8e489f 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -12,32 +12,105 @@ public class UpdatePackageStaticWebAssets : Task [Required] public ITaskItem[] Assets { get; set; } + // The intermediate output path for materializing framework assets (e.g., $(IntermediateOutputPath)staticwebassets\) + public string IntermediateOutputPath { get; set; } + + // The consuming project's PackageId + public string ProjectPackageId { get; set; } + + // The consuming project's StaticWebAssetBasePath + public string ProjectBasePath { get; set; } + [Output] public ITaskItem[] UpdatedAssets { get; set; } [Output] public ITaskItem[] OriginalAssets { get; set; } + // Framework assets that were materialized (original items, for endpoint remapping) + // ItemSpec = old asset Identity, NewPath metadata = new materialized path + [Output] + public ITaskItem[] MaterializedFrameworkAssets { get; set; } + + // Endpoints with AssetFile remapped for materialized framework assets + [Output] + public ITaskItem[] RemappedEndpoints { get; set; } + + // Original endpoints that were remapped (to remove from the endpoint list) + [Output] + public ITaskItem[] OriginalRemappedEndpoints { get; set; } + + // All endpoints for the consuming project (needed to remap framework asset endpoints) + public ITaskItem[] Endpoints { get; set; } + public override bool Execute() { try { var originalAssets = new List(); var updatedAssets = new List(); + var materializedFrameworkAssets = new List(); + 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, mapping) = MaterializeFrameworkAsset(candidate); + if (transformed != null) + { + updatedAssets.Add(transformed); + materializedFrameworkAssets.Add(mapping); + } } - - originalAssets.Add(candidate); - updatedAssets.Add(StaticWebAsset.FromV1TaskItem(candidate).ToTaskItem()); } OriginalAssets = [.. originalAssets]; UpdatedAssets = [.. updatedAssets]; + MaterializedFrameworkAssets = [.. materializedFrameworkAssets]; + + // Remap endpoints for materialized framework assets + var remappedEndpoints = new List(); + var originalRemappedEndpoints = new List(); + if (Endpoints != null && materializedFrameworkAssets.Count > 0) + { + // Build a mapping from old identity to new materialized path + var assetMapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var mapping in materializedFrameworkAssets) + { + var oldPath = mapping.ItemSpec; + var newPath = mapping.GetMetadata("NewPath"); + if (!string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(newPath)) + { + assetMapping[oldPath] = newPath; + } + } + + foreach (var endpoint in Endpoints) + { + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + { + originalRemappedEndpoints.Add(endpoint); + var remapped = new Microsoft.Build.Utilities.TaskItem(endpoint); + remapped.SetMetadata("AssetFile", newAssetFile); + remappedEndpoints.Add(remapped); + Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", + endpoint.ItemSpec, assetFile, newAssetFile); + } + } + } + + RemappedEndpoints = [.. remappedEndpoints]; + OriginalRemappedEndpoints = [.. originalRemappedEndpoints]; } catch (Exception ex) { @@ -46,4 +119,75 @@ public override bool Execute() return !Log.HasLoggedErrors; } + + private (ITaskItem, ITaskItem) MaterializeFrameworkAsset(ITaskItem candidate) + { + // Parse the asset from V1 task item format (applies defaults, normalizes, validates) + var asset = StaticWebAsset.FromV1TaskItem(candidate); + + var originalSourceId = asset.SourceId; + var relativePath = asset.RelativePath; + var oldIdentity = asset.Identity; + + // Compute materialized destination path: {IntermediateOutputPath}fx/{OriginalSourceId}/{RelativePath} + var fxDir = Path.Combine(IntermediateOutputPath, "fx", originalSourceId); + var destPath = Path.Combine(fxDir, StaticWebAsset.Normalize(relativePath)); + destPath = Path.GetFullPath(destPath); + + // Copy the file from the package cache to the intermediate output + var sourceFile = asset.Identity; + if (!File.Exists(sourceFile)) + { + // Let it throw naturally per the decisions document + Log.LogMessage(MessageImportance.Low, "Source file '{0}' does not exist for framework asset materialization.", sourceFile); + File.Copy(sourceFile, destPath); // This will throw FileNotFoundException + return (null, null); + } + + var destDir = Path.GetDirectoryName(destPath); + Directory.CreateDirectory(destDir); + + // Only copy if source is newer or dest doesn't exist + 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); + } + + // Transform the asset metadata to adopt it into the current project + asset.Identity = destPath; + asset.OriginalItemSpec = destPath; + asset.ContentRoot = EnsureTrailingSlash(Path.GetDirectoryName(Path.Combine(fxDir, "placeholder"))); + asset.SourceType = StaticWebAsset.SourceTypes.Discovered; + asset.SourceId = ProjectPackageId; + asset.BasePath = ProjectBasePath; + asset.AssetMode = StaticWebAsset.AssetModes.CurrentProject; + + // Recompute fingerprint/integrity since the file was copied (may have different metadata) + var fileInfo = new FileInfo(destPath); + var (fingerprint, integrity) = StaticWebAsset.ComputeFingerprintAndIntegrity(fileInfo); + asset.Fingerprint = fingerprint; + asset.Integrity = integrity; + asset.FileLength = fileInfo.Length; + asset.LastWriteTime = fileInfo.LastWriteTimeUtc; + + // Create mapping item for endpoint remapping (old identity -> new identity) + var mapping = new Microsoft.Build.Utilities.TaskItem(oldIdentity); + mapping.SetMetadata("NewPath", destPath); + + return (asset.ToTaskItem(), mapping); + } + + private static string EnsureTrailingSlash(string path) + { + if (!string.IsNullOrEmpty(path) && !path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + return path + Path.DirectorySeparatorChar; + } + return path; + } } From d3cdd4b6c77040e1f2effeff084c62d688e8f8ad Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 23 Feb 2026 18:50:24 +0100 Subject: [PATCH 2/9] Address implementation review findings for Framework Assets - Fix CreatePathString to include IsFramework() for consistent path computation - Remove MaterializedFrameworkAssets output (unused) - Change MaterializeFrameworkAsset return type to (StaticWebAsset, string) - Replace dead File.Copy throw with Log.LogError - Remove fingerprint/integrity/LastWriteTime recomputation (content identical) - Simplify ContentRoot to EnsureTrailingSlash(fxDir) - Fix EnsureTrailingSlash to handle both separator chars - Rework endpoint remapping to group by Identity (MSBuild Remove semantics) - Add Inputs/Outputs to UpdateExistingPackageStaticWebAssets target - Clean up restating comments in GenerateStaticWebAssetsPropsFile --- .../Microsoft.NET.Sdk.StaticWebAssets.targets | 6 +- .../Tasks/Data/StaticWebAsset.cs | 2 +- .../Tasks/GenerateStaticWebAssetsPropsFile.cs | 4 - .../Tasks/UpdatePackageStaticWebAssets.cs | 130 +++++++++--------- 4 files changed, 68 insertions(+), 74 deletions(-) diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets index 852f789dec80..a52870e331f6 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets @@ -741,7 +741,10 @@ Copyright (c) .NET Foundation. All rights reserved. - + - diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs index 06d024913b70..935b34bdf86a 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs @@ -1066,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 8eaee6c4af04..2ff0ff6759f3 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs @@ -40,8 +40,6 @@ public class GenerateStaticWebAssetsPropsFile : Task public bool AllowEmptySourceType { get; set; } - // Glob pattern for marking assets as Framework. Assets matching this pattern - // will be emitted with SourceType="Framework" instead of "Package". public string FrameworkPattern { get; set; } public override bool Execute() @@ -63,7 +61,6 @@ private bool ExecuteCore() var tokenResolver = StaticWebAssetTokenResolver.Instance; - // Build a glob matcher for the framework pattern if provided StaticWebAssetGlobMatcher frameworkMatcher = null; if (!string.IsNullOrEmpty(FrameworkPattern)) { @@ -89,7 +86,6 @@ private bool ExecuteCore() var relativePath = asset.ReplaceTokens(asset.RelativePath, tokenResolver); var fullPathExpression = @$"$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\{packagePath}'))"; - // Determine SourceType for the emitted item var emittedSourceType = "Package"; if (hasFrameworkMatcher) { diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index ad7c7e8e489f..b8567d26b62b 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -12,13 +12,10 @@ public class UpdatePackageStaticWebAssets : Task [Required] public ITaskItem[] Assets { get; set; } - // The intermediate output path for materializing framework assets (e.g., $(IntermediateOutputPath)staticwebassets\) public string IntermediateOutputPath { get; set; } - // The consuming project's PackageId public string ProjectPackageId { get; set; } - // The consuming project's StaticWebAssetBasePath public string ProjectBasePath { get; set; } [Output] @@ -27,20 +24,12 @@ public class UpdatePackageStaticWebAssets : Task [Output] public ITaskItem[] OriginalAssets { get; set; } - // Framework assets that were materialized (original items, for endpoint remapping) - // ItemSpec = old asset Identity, NewPath metadata = new materialized path - [Output] - public ITaskItem[] MaterializedFrameworkAssets { get; set; } - - // Endpoints with AssetFile remapped for materialized framework assets [Output] public ITaskItem[] RemappedEndpoints { get; set; } - // Original endpoints that were remapped (to remove from the endpoint list) [Output] public ITaskItem[] OriginalRemappedEndpoints { get; set; } - // All endpoints for the consuming project (needed to remap framework asset endpoints) public ITaskItem[] Endpoints { get; set; } public override bool Execute() @@ -49,7 +38,7 @@ public override bool Execute() { var originalAssets = new List(); var updatedAssets = new List(); - var materializedFrameworkAssets = new List(); + var assetMapping = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < Assets.Length; i++) { @@ -64,90 +53,109 @@ public override bool Execute() else if (StaticWebAsset.SourceTypes.IsFramework(sourceType)) { originalAssets.Add(candidate); - var (transformed, mapping) = MaterializeFrameworkAsset(candidate); + var (transformed, oldPath) = MaterializeFrameworkAsset(candidate); if (transformed != null) { - updatedAssets.Add(transformed); - materializedFrameworkAssets.Add(mapping); + updatedAssets.Add(transformed.ToTaskItem()); + assetMapping[oldPath] = transformed.Identity; } } } OriginalAssets = [.. originalAssets]; UpdatedAssets = [.. updatedAssets]; - MaterializedFrameworkAssets = [.. materializedFrameworkAssets]; - // Remap endpoints for materialized framework assets - var remappedEndpoints = new List(); - var originalRemappedEndpoints = new List(); - if (Endpoints != null && materializedFrameworkAssets.Count > 0) + RemapEndpoints(assetMapping); + } + catch (Exception ex) + { + Log.LogError(ex.ToString()); + } + + return !Log.HasLoggedErrors; + } + + private void RemapEndpoints(Dictionary assetMapping) + { + var remappedEndpoints = new List(); + var originalRemappedEndpoints = new List(); + + if (Endpoints != null && assetMapping.Count > 0) + { + var endpointsByIdentity = new Dictionary>(StringComparer.OrdinalIgnoreCase); + 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) { - // Build a mapping from old identity to new materialized path - var assetMapping = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var mapping in materializedFrameworkAssets) + var identity = kvp.Key; + var group = kvp.Value; + var groupNeedsRemapping = false; + foreach (var endpoint in group) { - var oldPath = mapping.ItemSpec; - var newPath = mapping.GetMetadata("NewPath"); - if (!string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(newPath)) + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.ContainsKey(assetFile)) { - assetMapping[oldPath] = newPath; + groupNeedsRemapping = true; + break; } } - foreach (var endpoint in Endpoints) + if (groupNeedsRemapping) { - var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + foreach (var endpoint in group) { originalRemappedEndpoints.Add(endpoint); + var remapped = new Microsoft.Build.Utilities.TaskItem(endpoint); - remapped.SetMetadata("AssetFile", newAssetFile); + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + { + remapped.SetMetadata("AssetFile", newAssetFile); + Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", + identity, assetFile, newAssetFile); + } + remappedEndpoints.Add(remapped); - Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", - endpoint.ItemSpec, assetFile, newAssetFile); } } } - - RemappedEndpoints = [.. remappedEndpoints]; - OriginalRemappedEndpoints = [.. originalRemappedEndpoints]; - } - catch (Exception ex) - { - Log.LogError(ex.ToString()); } - return !Log.HasLoggedErrors; + RemappedEndpoints = [.. remappedEndpoints]; + OriginalRemappedEndpoints = [.. originalRemappedEndpoints]; } - private (ITaskItem, ITaskItem) MaterializeFrameworkAsset(ITaskItem candidate) + private (StaticWebAsset, string) MaterializeFrameworkAsset(ITaskItem candidate) { - // Parse the asset from V1 task item format (applies defaults, normalizes, validates) var asset = StaticWebAsset.FromV1TaskItem(candidate); var originalSourceId = asset.SourceId; var relativePath = asset.RelativePath; var oldIdentity = asset.Identity; - // Compute materialized destination path: {IntermediateOutputPath}fx/{OriginalSourceId}/{RelativePath} var fxDir = Path.Combine(IntermediateOutputPath, "fx", originalSourceId); var destPath = Path.Combine(fxDir, StaticWebAsset.Normalize(relativePath)); destPath = Path.GetFullPath(destPath); - // Copy the file from the package cache to the intermediate output var sourceFile = asset.Identity; if (!File.Exists(sourceFile)) { - // Let it throw naturally per the decisions document - Log.LogMessage(MessageImportance.Low, "Source file '{0}' does not exist for framework asset materialization.", sourceFile); - File.Copy(sourceFile, destPath); // This will throw FileNotFoundException + Log.LogError("Source file '{0}' does not exist for framework asset materialization.", sourceFile); return (null, null); } var destDir = Path.GetDirectoryName(destPath); Directory.CreateDirectory(destDir); - // Only copy if source is newer or dest doesn't exist if (!File.Exists(destPath) || File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destPath)) { File.Copy(sourceFile, destPath, overwrite: true); @@ -158,36 +166,24 @@ public override bool Execute() Log.LogMessage(MessageImportance.Low, "Framework asset '{0}' already up to date at '{1}'.", sourceFile, destPath); } - // Transform the asset metadata to adopt it into the current project asset.Identity = destPath; asset.OriginalItemSpec = destPath; - asset.ContentRoot = EnsureTrailingSlash(Path.GetDirectoryName(Path.Combine(fxDir, "placeholder"))); + asset.ContentRoot = EnsureTrailingSlash(fxDir); asset.SourceType = StaticWebAsset.SourceTypes.Discovered; asset.SourceId = ProjectPackageId; asset.BasePath = ProjectBasePath; asset.AssetMode = StaticWebAsset.AssetModes.CurrentProject; - // Recompute fingerprint/integrity since the file was copied (may have different metadata) - var fileInfo = new FileInfo(destPath); - var (fingerprint, integrity) = StaticWebAsset.ComputeFingerprintAndIntegrity(fileInfo); - asset.Fingerprint = fingerprint; - asset.Integrity = integrity; - asset.FileLength = fileInfo.Length; - asset.LastWriteTime = fileInfo.LastWriteTimeUtc; - - // Create mapping item for endpoint remapping (old identity -> new identity) - var mapping = new Microsoft.Build.Utilities.TaskItem(oldIdentity); - mapping.SetMetadata("NewPath", destPath); - - return (asset.ToTaskItem(), mapping); + return (asset, oldIdentity); } private static string EnsureTrailingSlash(string path) { - if (!string.IsNullOrEmpty(path) && !path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + if (string.IsNullOrEmpty(path)) { - return path + Path.DirectorySeparatorChar; + return path; } - return path; + + return $"{path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)}{Path.DirectorySeparatorChar}"; } } From 297f2847c348b6eaa16f6f7848148ff81e72ac0b Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 23 Feb 2026 18:51:34 +0100 Subject: [PATCH 3/9] Remove Inputs/Outputs from target, rely on in-task incrementality The task already handles copy incrementality via timestamp comparison (File.GetLastWriteTimeUtc). Target-level Inputs/Outputs is problematic because empty Inputs (no Framework assets) causes MSBuild to skip the entire target, which also handles Package assets. This matches the pattern used by other tasks in the framework (e.g., GenerateStaticWebAssetsDevelopmentManifest, GenerateStaticWebAssetEndpointsManifest). --- .../Targets/Microsoft.NET.Sdk.StaticWebAssets.targets | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets index a52870e331f6..353de6a8233c 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets @@ -741,10 +741,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + Date: Mon, 23 Feb 2026 19:04:53 +0100 Subject: [PATCH 4/9] Simplify endpoint remapping: mutate in-place, guard at call site - Move Endpoints != null && assetMapping.Count > 0 guard to call site to avoid allocations when no framework assets exist - Remove TaskItem clone in remapping loop; mutate AssetFile in-place - Still two passes over each group (detect then update) since we need to know if the group needs remapping before modifying any items --- .../Tasks/UpdatePackageStaticWebAssets.cs | 77 ++++++++++--------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index b8567d26b62b..117531c11fbc 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -65,7 +65,10 @@ public override bool Execute() OriginalAssets = [.. originalAssets]; UpdatedAssets = [.. updatedAssets]; - RemapEndpoints(assetMapping); + if (Endpoints != null && assetMapping.Count > 0) + { + RemapEndpoints(assetMapping); + } } catch (Exception ex) { @@ -80,53 +83,51 @@ private void RemapEndpoints(Dictionary assetMapping) var remappedEndpoints = new List(); var originalRemappedEndpoints = new List(); - if (Endpoints != null && assetMapping.Count > 0) + var endpointsByIdentity = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in Endpoints) { - var endpointsByIdentity = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var endpoint in Endpoints) + var identity = endpoint.ItemSpec; + if (!endpointsByIdentity.TryGetValue(identity, out var group)) { - var identity = endpoint.ItemSpec; - if (!endpointsByIdentity.TryGetValue(identity, out var group)) - { - group = new List(); - endpointsByIdentity[identity] = group; - } - group.Add(endpoint); + group = new List(); + endpointsByIdentity[identity] = group; } + group.Add(endpoint); + } - foreach (var kvp in endpointsByIdentity) + foreach (var kvp in endpointsByIdentity) + { + var identity = kvp.Key; + var group = kvp.Value; + var groupNeedsRemapping = false; + foreach (var endpoint in group) { - 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)) { - var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.ContainsKey(assetFile)) - { - groupNeedsRemapping = true; - break; - } + groupNeedsRemapping = true; + break; } + } - if (groupNeedsRemapping) + if (!groupNeedsRemapping) + { + continue; + } + + foreach (var endpoint in group) + { + originalRemappedEndpoints.Add(endpoint); + + var assetFile = endpoint.GetMetadata("AssetFile"); + if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) { - foreach (var endpoint in group) - { - originalRemappedEndpoints.Add(endpoint); - - var remapped = new Microsoft.Build.Utilities.TaskItem(endpoint); - var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) - { - remapped.SetMetadata("AssetFile", newAssetFile); - Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", - identity, assetFile, newAssetFile); - } - - remappedEndpoints.Add(remapped); - } + endpoint.SetMetadata("AssetFile", newAssetFile); + Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", + identity, assetFile, newAssetFile); } + + remappedEndpoints.Add(endpoint); } } From 8bcc41333d05016f18aa6c6918173341878de313 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 23 Feb 2026 19:09:08 +0100 Subject: [PATCH 5/9] Single-pass endpoint remapping per group Mutate AssetFile as we iterate, then AddRange the group to output lists only if any endpoint was remapped. --- .../Tasks/UpdatePackageStaticWebAssets.cs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index 117531c11fbc..5cb83a701161 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -102,32 +102,20 @@ private void RemapEndpoints(Dictionary assetMapping) 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) - { - continue; - } - - foreach (var endpoint in group) - { - originalRemappedEndpoints.Add(endpoint); - var assetFile = endpoint.GetMetadata("AssetFile"); if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) { endpoint.SetMetadata("AssetFile", newAssetFile); Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", identity, assetFile, newAssetFile); + groupNeedsRemapping = true; } + } - remappedEndpoints.Add(endpoint); + if (groupNeedsRemapping) + { + originalRemappedEndpoints.AddRange(group); + remappedEndpoints.AddRange(group); } } From a7a55a5709c0e383d804d60c59ad4b2b76071fc5 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 23 Feb 2026 19:16:04 +0100 Subject: [PATCH 6/9] Add FrameworkAssetsSample test assets Test project pair for validating the Framework Assets feature: - FrameworkAssetsLib: RCL with StaticWebAssetFrameworkPattern=**/*.js - FrameworkAssetsConsumer: Web app consuming the lib as a PackageReference --- .../FrameworkAssetsConsumer.csproj | 22 ++++++++++++++ .../FrameworkAssetsConsumer/Program.cs | 4 +++ .../FrameworkAssetsLib.csproj | 28 ++++++++++++++++++ .../FrameworkAssetsLib/wwwroot/css/site.css | 2 ++ .../wwwroot/js/framework.js | 2 ++ .../FrameworkAssetsSample/NuGet.config | 8 +++++ .../packages/FrameworkAssetsLib.1.0.0.nupkg | Bin 0 -> 6367 bytes 7 files changed, 66 insertions(+) create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/css/site.css create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/wwwroot/js/framework.js create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config create mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj new file mode 100644 index 000000000000..61be1582a115 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj @@ -0,0 +1,22 @@ + + + + net11.0 + + + + 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..bf467e7535c3 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs @@ -0,0 +1,4 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapStaticAssets(); +app.Run(); diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj new file mode 100644 index 000000000000..e3bf8f3ca5f1 --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj @@ -0,0 +1,28 @@ + + + + net11.0 + true + © Microsoft + Framework Assets Test + Microsoft + Library producing framework assets + false + + + **/*.js + + + $(MSBuildThisFileDirectory)..\packages + 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"); diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config b/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config new file mode 100644 index 000000000000..91fa19e257cc --- /dev/null +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg b/test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..d745f7f7f7d4601b9f49f9a44f3efbc99d92b009 GIT binary patch literal 6367 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 MY5t`@?s%;K0Am-#2mk;8 literal 0 HcmV?d00001 From 4286b06456190dec24bd733522f80b2e0e6b3a28 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 24 Feb 2026 14:30:24 +0100 Subject: [PATCH 7/9] Add unit and integration tests for Framework Assets feature - UpdatePackageStaticWebAssetsTest: 16 unit tests covering - Package asset pass-through - Framework asset materialization (file copy, metadata mutation) - SourceType changed to Discovered, SourceId/BasePath/AssetMode updated - ContentRoot pointing to fx/ directory - Error handling for missing source files - Mixed asset processing (Package + Framework) - Fingerprint/Integrity preservation from original file - Incremental copy (skip when up-to-date, overwrite when stale) - Endpoint remapping (single, multi-identity, non-matching, null) - Subdirectory preservation in materialized paths - GenerateStaticWebAssetsPropsFileTest: 4 new tests for FrameworkPattern - SourceType=Framework emitted when pattern matches - All Package when pattern is null - Multiple semicolon-separated patterns - All Package when pattern matches nothing - FrameworkAssetsIntegrationTest: 6 integration tests - Pack produces nupkg with Framework SourceType in .props - Pack includes expected static web assets - Consumer build materializes framework assets to fx/ directory - Materialized file exists on disk - Endpoints remapped to materialized paths - Incremental build skips re-copy - Updated test assets for test infrastructure compatibility (AspNetTestTfm, AspNetTestPackageSource, EnsurePackagesExist target) --- .../FrameworkAssetsIntegrationTest.cs | 230 +++++++ .../GenerateStaticWebAssetsPropsFileTest.cs | 348 ++++++++++ .../UpdatePackageStaticWebAssetsTest.cs | 629 ++++++++++++++++++ .../FrameworkAssetsConsumer.csproj | 17 +- .../FrameworkAssetsLib.csproj | 5 +- .../FrameworkAssetsSample/NuGet.config | 8 - .../packages/FrameworkAssetsLib.1.0.0.nupkg | Bin 6367 -> 0 bytes 7 files changed, 1220 insertions(+), 17 deletions(-) create mode 100644 test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs create mode 100644 test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs delete mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config delete mode 100644 test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg 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..b4e36067d191 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs @@ -0,0 +1,230 @@ +// 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 fx directory + var fxDir = Path.Combine(intermediateOutputPath, "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 endpoints for framework.js point to the materialized file + var fxEndpoints = manifest.Endpoints + ?.Where(e => e.Route.Contains("framework.js")) + .ToList(); + + if (fxEndpoints != null && fxEndpoints.Count > 0) + { + foreach (var endpoint in fxEndpoints) + { + // The endpoint's AssetFile should point to the materialized path (under fx/) + endpoint.AssetFile.Should().Contain(Path.Combine("fx", "FrameworkAssetsLib"), + "endpoints for framework assets should point to the materialized file path"); + } + } + } + + [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, "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..78ec04d10938 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs @@ -0,0 +1,629 @@ +// 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(); + task.OriginalRemappedEndpoints.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); + task.OriginalRemappedEndpoints.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); + task.OriginalRemappedEndpoints.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 index 61be1582a115..09be01d91180 100644 --- a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/FrameworkAssetsConsumer.csproj @@ -1,18 +1,23 @@ - net11.0 + $(AspNetTestTfm) false - - - - - + + + + + + + + diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj index e3bf8f3ca5f1..4852822f19c0 100644 --- a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsLib/FrameworkAssetsLib.csproj @@ -1,7 +1,7 @@ - net11.0 + $(AspNetTestTfm) true © Microsoft Framework Assets Test @@ -12,8 +12,7 @@ **/*.js - - $(MSBuildThisFileDirectory)..\packages + $(AspNetTestPackageSource) 1.0.0 diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config b/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config deleted file mode 100644 index 91fa19e257cc..000000000000 --- a/test/TestAssets/TestProjects/FrameworkAssetsSample/NuGet.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg b/test/TestAssets/TestProjects/FrameworkAssetsSample/packages/FrameworkAssetsLib.1.0.0.nupkg deleted file mode 100644 index d745f7f7f7d4601b9f49f9a44f3efbc99d92b009..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6367 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 MY5t`@?s%;K0Am-#2mk;8 From 5c783be130dd1e112efa32b8ffd73d76f8207897 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 24 Feb 2026 15:09:04 +0100 Subject: [PATCH 8/9] Fix integration tests: correct materialization path and consumer Program.cs - Fix consumer Program.cs to use traditional Main() instead of top-level statements that depend on implicit usings unavailable in test env - Fix integration test assertions to look under staticwebassets/fx/ instead of fx/ (IntermediateOutputPath includes staticwebassets subdir) - Fix endpoint assertion to handle compressed endpoint variants --- .../FrameworkAssetsIntegrationTest.cs | 36 ++++++++++++------- .../FrameworkAssetsConsumer/Program.cs | 15 +++++--- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs index b4e36067d191..de37f48a78fa 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsIntegrationTest.cs @@ -148,8 +148,8 @@ public void Build_Consumer_MaterializedFrameworkAsset_FileExistsOnDisk() var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); - // The materialized file should exist on disk under the fx directory - var fxDir = Path.Combine(intermediateOutputPath, "fx", "FrameworkAssetsLib"); + // 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"); @@ -179,20 +179,30 @@ public void Build_Consumer_EndpointsRemapped_ForFrameworkAssets() var manifestPath = Path.Combine(intermediateOutputPath, "staticwebassets.build.json"); var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(manifestPath)); - // Check that endpoints for framework.js point to the materialized file + // 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(); - if (fxEndpoints != null && fxEndpoints.Count > 0) - { - foreach (var endpoint in fxEndpoints) - { - // The endpoint's AssetFile should point to the materialized path (under fx/) - endpoint.AssetFile.Should().Contain(Path.Combine("fx", "FrameworkAssetsLib"), - "endpoints for framework assets should point to the materialized file path"); - } - } + 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] @@ -214,7 +224,7 @@ public void Build_Consumer_IsIncremental() ExecuteCommand(build1).Should().Pass(); var intermediateOutputPath = build1.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); - var fxDir = Path.Combine(intermediateOutputPath, "fx", "FrameworkAssetsLib"); + var fxDir = Path.Combine(intermediateOutputPath, "staticwebassets", "fx", "FrameworkAssetsLib"); var materializedFile = Directory.GetFiles(fxDir, "framework.js", SearchOption.AllDirectories).Single(); var firstWriteTime = File.GetLastWriteTimeUtc(materializedFile); diff --git a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs index bf467e7535c3..d3f78b855c8a 100644 --- a/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs +++ b/test/TestAssets/TestProjects/FrameworkAssetsSample/FrameworkAssetsConsumer/Program.cs @@ -1,4 +1,11 @@ -var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); -app.MapStaticAssets(); -app.Run(); +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."); + } + } +} From f2a0c0b73afddba9a9760577af9fef9ac86cdac6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 25 Feb 2026 11:55:02 +0100 Subject: [PATCH 9/9] Address review: fix aliasing, normalize ContentRoot, use OSPath.PathComparer, Ordinal for routes - Remove OriginalRemappedEndpoints output; single RemappedEndpoints with cloned TaskItems - Use StaticWebAsset.NormalizeContentRootPath + asset.Normalize() instead of custom EnsureTrailingSlash - Switch assetMapping dictionary to OSPath.PathComparer for path keys - Use StringComparer.Ordinal for endpoint route grouping (matches RouteAndAssetComparer) --- .../Microsoft.NET.Sdk.StaticWebAssets.targets | 3 +- .../Tasks/UpdatePackageStaticWebAssets.cs | 43 +++++++++---------- .../UpdatePackageStaticWebAssetsTest.cs | 3 -- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets index 353de6a8233c..5dba7e8a9032 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets @@ -752,7 +752,6 @@ Copyright (c) .NET Foundation. All rights reserved. - @@ -771,7 +770,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index 5cb83a701161..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; @@ -27,9 +29,6 @@ public class UpdatePackageStaticWebAssets : Task [Output] public ITaskItem[] RemappedEndpoints { get; set; } - [Output] - public ITaskItem[] OriginalRemappedEndpoints { get; set; } - public ITaskItem[] Endpoints { get; set; } public override bool Execute() @@ -38,7 +37,7 @@ public override bool Execute() { var originalAssets = new List(); var updatedAssets = new List(); - var assetMapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + var assetMapping = new Dictionary(OSPath.PathComparer); for (var i = 0; i < Assets.Length; i++) { @@ -81,9 +80,8 @@ public override bool Execute() private void RemapEndpoints(Dictionary assetMapping) { var remappedEndpoints = new List(); - var originalRemappedEndpoints = new List(); - var endpointsByIdentity = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var endpointsByIdentity = new Dictionary>(StringComparer.Ordinal); foreach (var endpoint in Endpoints) { var identity = endpoint.ItemSpec; @@ -103,24 +101,31 @@ private void RemapEndpoints(Dictionary assetMapping) foreach (var endpoint in group) { var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + if (!string.IsNullOrEmpty(assetFile) && assetMapping.ContainsKey(assetFile)) { - endpoint.SetMetadata("AssetFile", newAssetFile); - Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", - identity, assetFile, newAssetFile); groupNeedsRemapping = true; + break; } } if (groupNeedsRemapping) { - originalRemappedEndpoints.AddRange(group); - remappedEndpoints.AddRange(group); + 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]; - OriginalRemappedEndpoints = [.. originalRemappedEndpoints]; } private (StaticWebAsset, string) MaterializeFrameworkAsset(ITaskItem candidate) @@ -157,22 +162,14 @@ private void RemapEndpoints(Dictionary assetMapping) asset.Identity = destPath; asset.OriginalItemSpec = destPath; - asset.ContentRoot = EnsureTrailingSlash(fxDir); + 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); } - private static string EnsureTrailingSlash(string path) - { - if (string.IsNullOrEmpty(path)) - { - return path; - } - - return $"{path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)}{Path.DirectorySeparatorChar}"; - } } diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs index 78ec04d10938..ad56be557e37 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs @@ -346,7 +346,6 @@ public void Execute_NoFrameworkAssets_EndpointsNotRemapped() result.Should().BeTrue(); // No framework assets => no remapping done task.RemappedEndpoints.Should().BeNullOrEmpty(); - task.OriginalRemappedEndpoints.Should().BeNullOrEmpty(); } [Fact] @@ -382,7 +381,6 @@ public void Execute_FrameworkAssets_EndpointsAreRemapped() // Assert result.Should().BeTrue(); task.RemappedEndpoints.Should().HaveCount(1); - task.OriginalRemappedEndpoints.Should().HaveCount(1); var remapped = task.RemappedEndpoints[0]; var expectedPath = Path.GetFullPath(Path.Combine(intermediateOutput, "fx", "FxLib", "framework.js")); @@ -431,7 +429,6 @@ public void Execute_MultipleEndpoints_SameIdentity_AllRemapped() // Assert result.Should().BeTrue(); task.RemappedEndpoints.Should().HaveCount(2); - task.OriginalRemappedEndpoints.Should().HaveCount(2); var expectedPath = Path.GetFullPath(Path.Combine(intermediateOutput, "fx", "FxLib", "framework.js")); task.RemappedEndpoints[0].GetMetadata("AssetFile").Should().Be(expectedPath);