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