diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Publish.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Publish.targets index f61a6a5d11c6..184d1fc3ca34 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Publish.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Publish.targets @@ -170,6 +170,9 @@ Copyright (c) .NET Foundation. All rights reserved. Assets="@(_ReferencedProjectPublishStaticWebAssetsUpdateCandidates)" Endpoints="@(_ReferencedProjectPublishStaticWebAssetEndpointsUpdateCandidates)" FingerprintInferenceExpressions="@(StaticWebAssetFingerprintInferenceExpression)" + IntermediateOutputPath="$(_StaticWebAssetsIntermediateOutputPath)" + ProjectPackageId="$(PackageId)" + ProjectBasePath="$(StaticWebAssetBasePath)" > diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.References.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.References.targets index aa6a42be84ef..a2ec9f2cf5f7 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.References.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.References.targets @@ -133,6 +133,9 @@ Copyright (c) .NET Foundation. All rights reserved. Endpoints="@(_ReferencedProjectBuildStaticWebAssetEndpointsUpdateCandidates)" FingerprintInferenceExpressions="@(StaticWebAssetFingerprintInferenceExpression)" StaticWebAssetGroups="@(StaticWebAssetGroup)" + IntermediateOutputPath="$(_StaticWebAssetsIntermediateOutputPath)" + ProjectPackageId="$(PackageId)" + ProjectBasePath="$(StaticWebAssetBasePath)" > @@ -184,6 +187,7 @@ Copyright (c) .NET Foundation. All rights reserved. AssetKind="Build" MakeReferencedAssetOriginalItemSpecAbsolute="$(StaticWebAssetMakeReferencedAssetOriginalItemSpecAbsolute)" Source="$(PackageId)" + FrameworkPattern="$(StaticWebAssetFrameworkPattern)" > diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets index 280c1ad571e0..be0818b36e48 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets @@ -840,6 +840,7 @@ Copyright (c) .NET Foundation. All rights reserved. + @@ -857,8 +858,8 @@ Copyright (c) .NET Foundation. All rights reserved. - - + + diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs index 0b29e62634f6..b1d053c46d8e 100644 --- a/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs +++ b/src/StaticWebAssetsSdk/Tasks/ComputeReferenceStaticWebAssetItems.cs @@ -28,6 +28,13 @@ public class ComputeReferenceStaticWebAssetItems : Task public bool MakeReferencedAssetOriginalItemSpecAbsolute { get; set; } + /// + /// Semicolon-separated glob patterns (e.g. **/*.js;**/*.wasm). + /// Assets whose RelativePath matches any pattern will have their + /// SourceType set to Framework. + /// + public string FrameworkPattern { get; set; } + [Output] public ITaskItem[] StaticWebAssets { get; set; } @@ -42,6 +49,10 @@ public override bool Execute() var resultAssets = new List(existingAssets.Count); var groupSet = new HashSet(StringComparer.Ordinal); + + var frameworkMatcher = CreateFrameworkMatcher(); + var matchContext = frameworkMatcher != null ? StaticWebAssetGlobMatcher.CreateMatchContext() : default; + foreach (var kvp in existingAssets) { var targetPath = kvp.Key; @@ -55,6 +66,7 @@ public override bool Execute() { if (ShouldIncludeAssetAsReference(groupedAsset, out var groupReason)) { + ApplyFrameworkPattern(groupedAsset, frameworkMatcher, ref matchContext); if (UpdateSourceType && !StaticWebAsset.SourceTypes.IsFramework(groupedAsset.SourceType)) { groupedAsset.SourceType = StaticWebAsset.SourceTypes.Project; @@ -78,6 +90,7 @@ public override bool Execute() if (ShouldIncludeAssetAsReference(selected, out var reason)) { + ApplyFrameworkPattern(selected, frameworkMatcher, ref matchContext); if (UpdateSourceType && !StaticWebAsset.SourceTypes.IsFramework(selected.SourceType)) { selected.SourceType = StaticWebAsset.SourceTypes.Project; @@ -123,6 +136,53 @@ public override bool Execute() return !Log.HasLoggedErrors; } + private StaticWebAssetGlobMatcher CreateFrameworkMatcher() + { + if (string.IsNullOrEmpty(FrameworkPattern)) + { + return null; + } + + var patterns = FrameworkPattern + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToArray(); + + if (patterns.Length == 0) + { + return null; + } + + return new StaticWebAssetGlobMatcherBuilder() + .AddIncludePatterns(patterns) + .Build(); + } + + private void ApplyFrameworkPattern( + StaticWebAsset asset, + StaticWebAssetGlobMatcher matcher, + ref StaticWebAssetGlobMatcher.MatchContext matchContext) + { + if (matcher == null || !asset.IsDiscovered()) + { + return; + } + + var relativePath = StaticWebAssetPathPattern.PathWithoutTokens(asset.RelativePath); + matchContext.SetPathAndReinitialize(relativePath.AsSpan()); + var match = matcher.Match(matchContext); + if (match.IsMatch) + { + asset.SourceType = StaticWebAsset.SourceTypes.Framework; + Log.LogMessage( + MessageImportance.Low, + "Asset '{0}' with relative path '{1}' matched framework pattern. Updating SourceType to Framework.", + asset.Identity, + relativePath); + } + } + private bool ShouldIncludeAssetAsReference(StaticWebAsset candidate, out string reason) { if (!StaticWebAssetsManifest.ManifestModes.ShouldIncludeAssetAsReference(candidate, ProjectMode)) diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs index c03a4c3796fa..6b42621bbf3a 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs @@ -9,6 +9,7 @@ using System.Security.Cryptography; using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -1136,6 +1137,62 @@ public bool ShouldCopyToPublishDirectory() public bool HasContentRoot(string path) => string.Equals(ContentRoot, NormalizeContentRootPath(path), StringComparison.Ordinal); + /// + /// Materializes a framework asset by copying it to the consuming project's intermediate directory + /// and updating its metadata (Identity, ContentRoot, SourceId, BasePath, SourceType, AssetMode). + /// Used by both the P2P and NuGet package paths. + /// Returns the old identity and old base path so callers can remap endpoints and related assets. + /// + public static (StaticWebAsset Asset, string OldIdentity, string OldBasePath) MaterializeFrameworkAsset( + StaticWebAsset asset, + string intermediateOutputPath, + string projectPackageId, + string projectBasePath, + TaskLoggingHelper log) + { + var originalSourceId = asset.SourceId; + var oldBasePath = asset.BasePath; + var relativePath = asset.RelativePath; + var oldIdentity = asset.Identity; + + var fxDir = Path.Combine(intermediateOutputPath, "fx", originalSourceId); + var fileSystemRelativePath = asset.ComputePathWithoutTokens(relativePath); + var destPath = Path.Combine(fxDir, Normalize(fileSystemRelativePath)); + 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, 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 = NormalizeContentRootPath(fxDir); + asset.SourceType = SourceTypes.Discovered; + asset.SourceId = projectPackageId; + asset.BasePath = projectBasePath; + asset.AssetMode = AssetModes.CurrentProject; + asset.AssetGroups = ""; + asset.Normalize(); + + return (asset, oldIdentity, oldBasePath); + } + public static string Normalize(string path, bool allowEmpyPath = false) { var normalizedPath = path.Replace('\\', '/').Trim('/'); diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs index 060adf14282b..b2fee114510b 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs @@ -605,4 +605,68 @@ public static bool RouteHasPathPrefix( return true; } + + /// + /// Remaps the Route (and label endpoint property) on an endpoint by stripping the old base path + /// prefix and prepending the new base path. Used when materializing framework assets to update + /// endpoint routes from the library's base path to the consuming project's base path. + /// + public static void RemapEndpointRoute( + StaticWebAssetEndpoint endpoint, + string oldBasePath, + string newBasePath, + List routeSegments, + List basePathSegments) + { + var normalizedOldBase = oldBasePath is null or "/" ? "" : StaticWebAsset.Normalize(oldBasePath); + + if (!string.IsNullOrEmpty(normalizedOldBase) && + RouteHasPathPrefix(endpoint.Route, normalizedOldBase, routeSegments, basePathSegments)) + { + var remaining = endpoint.Route.Length > normalizedOldBase.Length + ? endpoint.Route.Substring(normalizedOldBase.Length).TrimStart('/') + : ""; + endpoint.Route = StaticWebAsset.CombineNormalizedPaths("", newBasePath, remaining, '/'); + + // Also remap the label endpoint property (used by fingerprinting/HTML asset placeholders). + RemapLabelProperty(endpoint, normalizedOldBase, newBasePath, routeSegments, basePathSegments); + } + else if (string.IsNullOrEmpty(normalizedOldBase)) + { + // Old base path was empty/root — prepend the new base path to the existing route. + endpoint.Route = StaticWebAsset.CombineNormalizedPaths("", newBasePath, endpoint.Route, '/'); + + for (var j = 0; j < endpoint.EndpointProperties.Length; j++) + { + ref var property = ref endpoint.EndpointProperties[j]; + if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase)) + { + property.Value = StaticWebAsset.CombineNormalizedPaths("", newBasePath, property.Value, '/'); + endpoint.MarkProperiesAsModified(); + } + } + } + } + + private static void RemapLabelProperty( + StaticWebAssetEndpoint endpoint, + string normalizedOldBase, + string newBasePath, + List routeSegments, + List basePathSegments) + { + for (var j = 0; j < endpoint.EndpointProperties.Length; j++) + { + ref var property = ref endpoint.EndpointProperties[j]; + if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase) && + RouteHasPathPrefix(property.Value, normalizedOldBase, routeSegments, basePathSegments)) + { + var labelRemaining = property.Value.Length > normalizedOldBase.Length + ? property.Value.Substring(normalizedOldBase.Length).TrimStart('/') + : ""; + property.Value = StaticWebAsset.CombineNormalizedPaths("", newBasePath, labelRemaining, '/'); + endpoint.MarkProperiesAsModified(); + } + } + } } diff --git a/src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs index a918fd6a8458..688be8f05198 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs @@ -15,6 +15,8 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; // For example, the JavaScript Project Tools SDK integrates with the static web asset protocol for SPA applications // but it doesn't support integrity or fingerprinting, which causes issues when we reference the project and we try // to further process the assets. +// Additionally, this task handles incoming framework assets from P2P references by materializing them +// (copying files to an intermediate directory and updating metadata) so they become local to the consuming project. public class UpdateExternallyDefinedStaticWebAssets : Task { [Required] @@ -27,6 +29,12 @@ public class UpdateExternallyDefinedStaticWebAssets : Task public ITaskItem[] StaticWebAssetGroups { get; set; } + public string IntermediateOutputPath { get; set; } + + public string ProjectPackageId { get; set; } + + public string ProjectBasePath { get; set; } + [Output] public ITaskItem[] UpdatedAssets { get; set; } @@ -36,6 +44,9 @@ public class UpdateExternallyDefinedStaticWebAssets : Task [Output] public ITaskItem[] AssetsWithoutEndpoints { get; set; } + [Output] + public ITaskItem[] OriginalFrameworkAssets { get; set; } + public override bool Execute() { var assets = Assets.Select(StaticWebAsset.FromV1TaskItem).ToArray(); @@ -47,11 +58,41 @@ public override bool Execute() var fingerprintExpressions = CreateFingerprintExpressions(FingerprintInferenceExpressions); + // Filter by group FIRST so that framework assets tagged with groups that the consuming + // project doesn't accept are excluded before materialization. + var (filteredAssets, excludedAssetFiles) = StaticWebAsset.FilterByGroup(assets, groupLookup, skipDeferred: true); + + // Rebuild the assets array from filtered results for subsequent processing. + // Also build a set to identify which original input items survived filtering. + var filteredSet = new HashSet(filteredAssets.Select(a => a.Identity), OSPath.PathComparer); + var assetsWithoutEndpoints = new List(); + var originalFrameworkAssetItems = new List(); + var assetMapping = new Dictionary(OSPath.PathComparer); - for (var i = 0; i < assets.Length; i++) + for (var i = 0; i < filteredAssets.Count; i++) { - var asset = assets[i]; + var asset = filteredAssets[i]; + + // Materialize framework assets from P2P references. + if (StaticWebAsset.SourceTypes.IsFramework(asset.SourceType)) + { + // Find the original task item that corresponds to this filtered asset. + var originalIndex = Array.FindIndex(assets, a => OSPath.PathComparer.Equals(a.Identity, asset.Identity)); + if (originalIndex >= 0) + { + originalFrameworkAssetItems.Add(Assets[originalIndex]); + } + var (materialized, oldIdentity, oldBasePath) = StaticWebAsset.MaterializeFrameworkAsset( + asset, IntermediateOutputPath, ProjectPackageId, ProjectBasePath, Log); + if (materialized != null) + { + filteredAssets[i] = materialized; + assetMapping[oldIdentity] = (materialized.Identity, oldBasePath); + } + continue; + } + if (!endpointByAsset.TryGetValue(asset.Identity, out var endpoint)) { Log.LogMessage($"Asset {asset.Identity} does not have an associated endpoint defined."); @@ -67,18 +108,50 @@ public override bool Execute() } } - var (filteredAssets, excludedAssetFiles) = StaticWebAsset.FilterByGroup(assets, groupLookup, skipDeferred: true); + // Update RelatedAsset on compressed/alternative assets that reference materialized framework assets. + if (assetMapping.Count > 0) + { + for (var i = 0; i < filteredAssets.Count; i++) + { + var asset = filteredAssets[i]; + if (!string.IsNullOrEmpty(asset.RelatedAsset) && + assetMapping.TryGetValue(asset.RelatedAsset, out var mapping)) + { + asset.RelatedAsset = mapping.NewIdentity; + } + } + } UpdatedAssets = StaticWebAsset.ToTaskItems(filteredAssets); // Filter endpoints using the shared helper. var endpointGroups = StaticWebAssetEndpointGroup.CreateEndpointGroups(endpoints); var (_, survivingEndpoints) = StaticWebAssetEndpointGroup.ComputeFilteredEndpoints(endpointGroups, excludedAssetFiles); + + // Remap endpoints for materialized framework assets — update AssetFile and Route + // to reflect the new materialized path and the consuming project's base path. + if (assetMapping.Count > 0) + { + var routeSegments = new List(); + var basePathSegments = new List(); + + foreach (var ep in survivingEndpoints) + { + if (assetMapping.TryGetValue(ep.AssetFile, out var info)) + { + ep.AssetFile = info.NewIdentity; + StaticWebAssetEndpoint.RemapEndpointRoute(ep, info.OldBasePath, ProjectBasePath, routeSegments, basePathSegments); + } + } + } + UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(survivingEndpoints); AssetsWithoutEndpoints = StaticWebAsset.ToTaskItems( assetsWithoutEndpoints.Where(a => !excludedAssetFiles.Contains(a.Identity))); + OriginalFrameworkAssets = [.. originalFrameworkAssetItems]; + return !Log.HasLoggedErrors; } diff --git a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs index fcac8d28126e..fd09b97cf2d1 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdatePackageStaticWebAssets.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -29,6 +28,9 @@ public class UpdatePackageStaticWebAssets : Task [Output] public ITaskItem[] RemappedEndpoints { get; set; } + [Output] + public ITaskItem[] OriginalFrameworkEndpoints { get; set; } + public ITaskItem[] Endpoints { get; set; } public override bool Execute() @@ -37,7 +39,7 @@ public override bool Execute() { var originalAssets = new List(); var updatedAssets = new List(); - var assetMapping = new Dictionary(OSPath.PathComparer); + var assetMapping = new Dictionary(OSPath.PathComparer); for (var i = 0; i < Assets.Length; i++) { @@ -52,11 +54,13 @@ public override bool Execute() else if (StaticWebAsset.SourceTypes.IsFramework(sourceType)) { originalAssets.Add(candidate); - var (transformed, oldPath) = MaterializeFrameworkAsset(candidate); + var asset = StaticWebAsset.FromV1TaskItem(candidate); + var (transformed, oldPath, oldBasePath) = StaticWebAsset.MaterializeFrameworkAsset( + asset, IntermediateOutputPath, ProjectPackageId, ProjectBasePath, Log); if (transformed != null) { updatedAssets.Add(transformed.ToTaskItem()); - assetMapping[oldPath] = transformed.Identity; + assetMapping[oldPath] = (transformed.Identity, oldBasePath); } } } @@ -77,31 +81,34 @@ public override bool Execute() return !Log.HasLoggedErrors; } - private void RemapEndpoints(Dictionary assetMapping) + private void RemapEndpoints(Dictionary assetMapping) { - var remappedEndpoints = new List(); + var remappedEndpoints = new List(); + var originalEndpointItems = new List(); + var parsedEndpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints); - var endpointsByIdentity = new Dictionary>(StringComparer.Ordinal); - foreach (var endpoint in Endpoints) + var endpointsByRoute = new Dictionary>(StringComparer.Ordinal); + for (var i = 0; i < parsedEndpoints.Length; i++) { - var identity = endpoint.ItemSpec; - if (!endpointsByIdentity.TryGetValue(identity, out var group)) + var endpoint = parsedEndpoints[i]; + if (!endpointsByRoute.TryGetValue(endpoint.Route, out var group)) { - group = new List(); - endpointsByIdentity[identity] = group; + group = []; + endpointsByRoute[endpoint.Route] = group; } - group.Add(endpoint); + group.Add((endpoint, i)); } - foreach (var kvp in endpointsByIdentity) + var routeSegments = new List(); + var basePathSegments = new List(); + + foreach (var kvp in endpointsByRoute) { - var identity = kvp.Key; var group = kvp.Value; var groupNeedsRemapping = false; - foreach (var endpoint in group) + foreach (var (endpoint, _) in group) { - var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.ContainsKey(assetFile)) + if (!string.IsNullOrEmpty(endpoint.AssetFile) && assetMapping.ContainsKey(endpoint.AssetFile)) { groupNeedsRemapping = true; break; @@ -110,67 +117,28 @@ private void RemapEndpoints(Dictionary assetMapping) if (groupNeedsRemapping) { - foreach (var endpoint in group) + foreach (var (endpoint, originalIndex) in group) { - var newEndpoint = new TaskItem(endpoint); - var assetFile = endpoint.GetMetadata("AssetFile"); - if (!string.IsNullOrEmpty(assetFile) && assetMapping.TryGetValue(assetFile, out var newAssetFile)) + // Capture the original endpoint task item for removal. + originalEndpointItems.Add(Endpoints[originalIndex]); + + if (!string.IsNullOrEmpty(endpoint.AssetFile) && assetMapping.TryGetValue(endpoint.AssetFile, out var info)) { - newEndpoint.SetMetadata("AssetFile", newAssetFile); - Log.LogMessage(MessageImportance.Low, "Remapped endpoint '{0}' AssetFile from '{1}' to '{2}'.", - identity, assetFile, newAssetFile); + var oldAssetFile = endpoint.AssetFile; + endpoint.AssetFile = info.NewIdentity; + + StaticWebAssetEndpoint.RemapEndpointRoute(endpoint, info.OldBasePath, ProjectBasePath, routeSegments, basePathSegments); + + Log.LogMessage(MessageImportance.Low, "Remapped endpoint route from '{0}' to '{1}', AssetFile from '{2}' to '{3}'.", + kvp.Key, endpoint.Route, oldAssetFile, info.NewIdentity); } - remappedEndpoints.Add(newEndpoint); + remappedEndpoints.Add(endpoint); } } } - 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 fileSystemRelativePath = asset.ComputePathWithoutTokens(relativePath); - var destPath = Path.Combine(fxDir, StaticWebAsset.Normalize(fileSystemRelativePath)); - 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); + OriginalFrameworkEndpoints = [.. originalEndpointItems]; + RemappedEndpoints = StaticWebAssetEndpoint.ToTaskItems(remappedEndpoints); } } diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsP2PIntegrationTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsP2PIntegrationTest.cs new file mode 100644 index 000000000000..03f64c4ec58e --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/FrameworkAssetsP2PIntegrationTest.cs @@ -0,0 +1,135 @@ +// 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.Xml.Linq; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests +{ + public class FrameworkAssetsP2PIntegrationTest(ITestOutputHelper log) : AspNetSdkTest(log) + { + [Fact] + public void Build_Consumer_MaterializesFrameworkAssetsFromProjectReference() + { + var intermediateOutputPath = BuildConsumerWithFrameworkPattern(); + var manifest = LoadBuildManifest(intermediateOutputPath); + + // The JS assets matching the FrameworkPattern should be materialized under fx/ + var materializedAssets = manifest.Assets + .Where(a => a.RelativePath.Contains(".js") + && a.Identity.Contains(Path.Combine("fx", "ClassLibrary"))) + .ToList(); + + materializedAssets.Should().NotBeEmpty( + "JS assets matching FrameworkPattern should be materialized under the fx/ directory"); + + foreach (var asset in materializedAssets) + { + // SourceType should be Discovered (changed from Framework during materialization) + asset.SourceType.Should().Be("Discovered", + $"materialized framework asset {asset.RelativePath} should have SourceType=Discovered"); + + // SourceId should be updated to the consuming project's PackageId + asset.SourceId.Should().Be("AppWithP2PReference", + $"materialized framework asset {asset.RelativePath} should have SourceId updated to the consumer"); + + // BasePath should be the consumer's base path ("/" for a web app) + asset.BasePath.Should().Be("/", + $"materialized framework asset {asset.RelativePath} should have BasePath updated to the consumer"); + + // AssetMode should be CurrentProject + asset.AssetMode.Should().Be("CurrentProject", + $"materialized framework asset {asset.RelativePath} should have AssetMode=CurrentProject"); + } + } + + [Fact] + public void Build_Consumer_NonMatchingAssetsRemainUnchanged() + { + var intermediateOutputPath = BuildConsumerWithFrameworkPattern(); + var manifest = LoadBuildManifest(intermediateOutputPath); + + // CSS assets from ClassLibrary should remain as Project type (they don't match **/*.js) + var cssAssets = manifest.Assets + .Where(a => a.RelativePath.Contains(".css") && a.SourceId == "ClassLibrary") + .ToList(); + + cssAssets.Should().NotBeEmpty("CSS assets from ClassLibrary should be present"); + cssAssets.Should().OnlyContain(a => a.SourceType == "Project", + "CSS assets not matching FrameworkPattern should remain as Project type"); + } + + [Fact] + public void Build_Consumer_MaterializedFrameworkAssetFilesExistOnDisk() + { + var intermediateOutputPath = BuildConsumerWithFrameworkPattern(); + + var fxDir = Path.Combine(intermediateOutputPath, "staticwebassets", "fx", "ClassLibrary"); + Directory.Exists(fxDir).Should().BeTrue("the fx/ClassLibrary directory should be created"); + + var materializedFiles = Directory.GetFiles(fxDir, "*.js", SearchOption.AllDirectories); + materializedFiles.Should().NotBeEmpty("JS framework assets should be copied to the fx/ directory"); + } + + [Fact] + public void Build_Consumer_EndpointsExistForMaterializedFrameworkAssets() + { + var intermediateOutputPath = BuildConsumerWithFrameworkPattern(); + var manifest = LoadBuildManifest(intermediateOutputPath); + + var materializedAssets = manifest.Assets + .Where(a => a.RelativePath.Contains(".js") + && a.Identity.Contains(Path.Combine("fx", "ClassLibrary"))) + .ToList(); + + materializedAssets.Should().NotBeEmpty(); + + // Each materialized asset should have at least one endpoint referencing it + foreach (var asset in materializedAssets) + { + var matchingEndpoints = manifest.Endpoints + ?.Where(e => string.Equals(e.AssetFile, asset.Identity, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + matchingEndpoints.Should().NotBeNullOrEmpty( + $"materialized framework asset {asset.RelativePath} should have at least one endpoint"); + + // Endpoint routes should NOT contain the library's base path — they should + // reflect the consumer's base path (which is "/" for a web app). + foreach (var ep in matchingEndpoints) + { + ep.Route.Should().NotContain("_content/ClassLibrary", + "endpoint route should not retain the library's base path after materialization"); + } + } + } + + private string BuildConsumerWithFrameworkPattern() + { + var projectDirectory = CreateAspNetSdkTestAsset("RazorAppWithP2PReference") + .WithProjectChanges((path, document) => + { + if (Path.GetFileName(path) == "ClassLibrary.csproj") + { + // Add FrameworkPattern to mark all .js files as framework assets + var propertyGroup = document.Root.Descendants("TargetFramework").First().Parent; + propertyGroup.Add( + new XElement("StaticWebAssetFrameworkPattern", "**/*.js")); + } + }); + + var build = CreateBuildCommand(projectDirectory, "AppWithP2PReference"); + ExecuteCommand(build).Should().Pass(); + + return build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + } + + private static StaticWebAssetsManifest LoadBuildManifest(string intermediateOutputPath) + { + var path = Path.Combine(intermediateOutputPath, "staticwebassets.build.json"); + return StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(path)); + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs index 771984bb8d42..cea416a71f0b 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeReferenceStaticWebAssetItemsTest.cs @@ -336,6 +336,101 @@ public void FiltersAssetsFromPackages() task.StaticWebAssets.Should().HaveCount(0); } + [Fact] + public void AppliesFrameworkPatternToDiscoveredAssets() + { + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new ComputeReferenceStaticWebAssetItems + { + BuildEngine = buildEngine.Object, + Source = "MyPackage", + Assets = new[] + { + CreateCandidate(Path.Combine("wwwroot", "framework.js"), "MyPackage", "Discovered", "framework.js", "All", "All"), + CreateCandidate(Path.Combine("wwwroot", "app.css"), "MyPackage", "Discovered", "app.css", "All", "All") + }, + Patterns = new ITaskItem[] { }, + AssetKind = "Build", + ProjectMode = "Default", + FrameworkPattern = "**/*.js" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().Be(true); + task.StaticWebAssets.Should().HaveCount(2); + task.StaticWebAssets[0].GetMetadata("SourceType").Should().Be("Framework"); + task.StaticWebAssets[1].GetMetadata("SourceType").Should().Be("Project"); + } + + [Fact] + public void FrameworkPatternDoesNotAffectNonDiscoveredAssets() + { + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new ComputeReferenceStaticWebAssetItems + { + BuildEngine = buildEngine.Object, + Source = "MyPackage", + Assets = new[] { CreateCandidate(Path.Combine("wwwroot", "candidate.js"), "MyPackage", "Project", "candidate.js", "All", "All") }, + Patterns = new ITaskItem[] { }, + AssetKind = "Build", + ProjectMode = "Default", + UpdateSourceType = false, + FrameworkPattern = "**/*.js" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().Be(true); + task.StaticWebAssets.Should().HaveCount(1); + task.StaticWebAssets[0].GetMetadata("SourceType").Should().Be("Project"); + } + + [Fact] + public void PreservesAssetGroupsOnFrameworkAssets() + { + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var asset = CreateCandidate(Path.Combine("wwwroot", "framework.js"), "MyPackage", "Framework", "framework.js", "All", "All"); + asset.SetMetadata("AssetGroups", "MyGroup"); + + var task = new ComputeReferenceStaticWebAssetItems + { + BuildEngine = buildEngine.Object, + Source = "MyPackage", + Assets = new[] { asset }, + Patterns = new ITaskItem[] { }, + AssetKind = "Build", + ProjectMode = "Default", + UpdateSourceType = true + }; + + // Act + var result = task.Execute(); + + // Assert — groups are preserved here so FilterByGroup can evaluate them; + // clearing happens later during MaterializeFrameworkAsset. + result.Should().Be(true); + task.StaticWebAssets.Should().HaveCount(1); + task.StaticWebAssets[0].GetMetadata("SourceType").Should().Be("Framework"); + task.StaticWebAssets[0].GetMetadata("AssetGroups").Should().Be("MyGroup"); + } + private static ITaskItem CreateCandidate( string itemSpec, string sourceId, diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdateExternallyDefinedStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdateExternallyDefinedStaticWebAssetsTest.cs index 9c2e008f4610..1c64f37d3d00 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdateExternallyDefinedStaticWebAssetsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdateExternallyDefinedStaticWebAssetsTest.cs @@ -254,4 +254,275 @@ public void Execute_InfersFingerprint_ForMatchingAssets() task.UpdatedAssets[0].GetMetadata("Integrity").Should().NotBeNullOrEmpty(); task.UpdatedAssets[1].GetMetadata("Integrity").Should().NotBeNullOrEmpty(); } + + [Fact] + public void Execute_MaterializesFrameworkAssetsFromP2PReferences() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var intermediateDir = Path.Combine(AppContext.BaseDirectory, "obj", "fxtest"); + var sourceDir = Path.Combine(AppContext.BaseDirectory, "fxsource"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "framework.js"); + File.WriteAllText(sourceFile, "// framework"); + + var asset = new TaskItem( + sourceFile, + new Dictionary + { + ["RelativePath"] = "framework.js", + ["BasePath"] = "_content/SourceLib", + ["AssetMode"] = "All", + ["AssetKind"] = "Build", + ["SourceId"] = "SourceLib", + ["CopyToOutputDirectory"] = "PreserveNewest", + ["RelatedAsset"] = "", + ["ContentRoot"] = sourceDir + Path.DirectorySeparatorChar, + ["SourceType"] = "Framework", + ["AssetRole"] = "Primary", + ["AssetTraitValue"] = "", + ["AssetTraitName"] = "", + ["OriginalItemSpec"] = sourceFile, + ["CopyToPublishDirectory"] = "PreserveNewest" + }); + + var task = new UpdateExternallyDefinedStaticWebAssets + { + Assets = new[] { asset }, + Endpoints = [], + BuildEngine = buildEngine.Object, + IntermediateOutputPath = intermediateDir, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/ConsumerApp" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + var materialized = task.UpdatedAssets[0]; + materialized.GetMetadata("SourceType").Should().Be("Discovered"); + materialized.GetMetadata("SourceId").Should().Be("ConsumerApp"); + materialized.GetMetadata("BasePath").Should().Be("_content/ConsumerApp"); + materialized.GetMetadata("AssetMode").Should().Be("CurrentProject"); + materialized.ItemSpec.Should().Contain(Path.Combine("fx", "SourceLib")); + File.Exists(materialized.ItemSpec).Should().BeTrue(); + + task.OriginalFrameworkAssets.Should().HaveCount(1); + task.OriginalFrameworkAssets[0].GetMetadata("SourceType").Should().Be("Framework"); + } + + [Fact] + public void Execute_RemapsEndpointRoutesForMaterializedFrameworkAssets() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var intermediateDir = Path.Combine(AppContext.BaseDirectory, "obj", "fxroute"); + var sourceDir = Path.Combine(AppContext.BaseDirectory, "fxroutesource"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "framework.js"); + File.WriteAllText(sourceFile, "// framework route test"); + + var asset = new TaskItem( + sourceFile, + new Dictionary + { + ["RelativePath"] = "js/framework.js", + ["BasePath"] = "_content/SourceLib", + ["AssetMode"] = "All", + ["AssetKind"] = "Build", + ["SourceId"] = "SourceLib", + ["CopyToOutputDirectory"] = "PreserveNewest", + ["RelatedAsset"] = "", + ["ContentRoot"] = sourceDir + Path.DirectorySeparatorChar, + ["SourceType"] = "Framework", + ["AssetRole"] = "Primary", + ["AssetTraitValue"] = "", + ["AssetTraitName"] = "", + ["OriginalItemSpec"] = sourceFile, + ["CopyToPublishDirectory"] = "PreserveNewest" + }); + + // Endpoint with the old base path baked into the route. + var endpoint = new TaskItem( + "_content/SourceLib/js/framework.js", + new Dictionary + { + ["Route"] = "_content/SourceLib/js/framework.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = """[{"Name":"label","Value":"_content/SourceLib/js/framework.js"}]""" + }); + + var task = new UpdateExternallyDefinedStaticWebAssets + { + Assets = [asset], + Endpoints = [endpoint], + BuildEngine = buildEngine.Object, + IntermediateOutputPath = intermediateDir, + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "/" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedEndpoints.Should().HaveCount(1); + var updatedEndpoint = task.UpdatedEndpoints[0]; + + // Route should have old base path stripped and new base path applied. + // "/" base path means just the relative path remains. + updatedEndpoint.ItemSpec.Should().Be("js/framework.js", + "endpoint route should have old base path '_content/SourceLib' stripped"); + + // AssetFile should point to the materialized path. + updatedEndpoint.GetMetadata("AssetFile").Should().Contain(Path.Combine("fx", "SourceLib")); + + // Label endpoint property should also be remapped. + var endpointProperties = updatedEndpoint.GetMetadata("EndpointProperties"); + endpointProperties.Should().Contain("js/framework.js"); + endpointProperties.Should().NotContain("_content/SourceLib"); + } + + [Fact] + public void Execute_RemapsEndpointRoutesToConsumerBasePath() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var intermediateDir = Path.Combine(AppContext.BaseDirectory, "obj", "fxroutelib"); + var sourceDir = Path.Combine(AppContext.BaseDirectory, "fxroutelibsource"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "lib.js"); + File.WriteAllText(sourceFile, "// lib route test"); + + var asset = new TaskItem( + sourceFile, + new Dictionary + { + ["RelativePath"] = "js/lib.js", + ["BasePath"] = "_content/SourceLib", + ["AssetMode"] = "All", + ["AssetKind"] = "Build", + ["SourceId"] = "SourceLib", + ["CopyToOutputDirectory"] = "PreserveNewest", + ["RelatedAsset"] = "", + ["ContentRoot"] = sourceDir + Path.DirectorySeparatorChar, + ["SourceType"] = "Framework", + ["AssetRole"] = "Primary", + ["AssetTraitValue"] = "", + ["AssetTraitName"] = "", + ["OriginalItemSpec"] = sourceFile, + ["CopyToPublishDirectory"] = "PreserveNewest" + }); + + var endpoint = new TaskItem( + "_content/SourceLib/js/lib.js", + new Dictionary + { + ["Route"] = "_content/SourceLib/js/lib.js", + ["AssetFile"] = sourceFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = """[{"Name":"label","Value":"_content/SourceLib/js/lib.js"}]""" + }); + + var task = new UpdateExternallyDefinedStaticWebAssets + { + Assets = [asset], + Endpoints = [endpoint], + BuildEngine = buildEngine.Object, + IntermediateOutputPath = intermediateDir, + ProjectPackageId = "ConsumerLib", + ProjectBasePath = "_content/ConsumerLib" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedEndpoints.Should().HaveCount(1); + var updatedEndpoint = task.UpdatedEndpoints[0]; + + // Route should have old base path replaced with consumer's base path. + updatedEndpoint.ItemSpec.Should().Be("_content/ConsumerLib/js/lib.js", + "endpoint route should use consumer's base path"); + + // Label should also reflect the new base path. + var endpointProperties = updatedEndpoint.GetMetadata("EndpointProperties"); + endpointProperties.Should().Contain("_content/ConsumerLib/js/lib.js"); + endpointProperties.Should().NotContain("_content/SourceLib"); + } + + [Fact] + public void Execute_PassesThroughNonFrameworkAssetsUnchanged() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var sourceDir = Path.Combine(AppContext.BaseDirectory, "normalsource"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, "app.js"); + File.WriteAllText(sourceFile, "// app"); + + var asset = new TaskItem( + sourceFile, + new Dictionary + { + ["RelativePath"] = "app.js", + ["BasePath"] = "", + ["AssetMode"] = "All", + ["AssetKind"] = "Build", + ["SourceId"] = "OtherLib", + ["CopyToOutputDirectory"] = "PreserveNewest", + ["RelatedAsset"] = "", + ["ContentRoot"] = sourceDir + Path.DirectorySeparatorChar, + ["SourceType"] = "Discovered", + ["AssetRole"] = "Primary", + ["AssetTraitValue"] = "", + ["AssetTraitName"] = "", + ["OriginalItemSpec"] = sourceFile, + ["CopyToPublishDirectory"] = "PreserveNewest" + }); + + var task = new UpdateExternallyDefinedStaticWebAssets + { + Assets = new[] { asset }, + Endpoints = [], + BuildEngine = buildEngine.Object, + IntermediateOutputPath = Path.Combine(AppContext.BaseDirectory, "obj", "normal"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "_content/ConsumerApp" + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.UpdatedAssets.Should().HaveCount(1); + task.UpdatedAssets[0].GetMetadata("SourceType").Should().Be("Discovered"); + task.UpdatedAssets[0].GetMetadata("SourceId").Should().Be("OtherLib"); + task.OriginalFrameworkAssets.Should().BeEmpty(); + } } diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs index ad56be557e37..e16ab07c55af 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/UpdatePackageStaticWebAssetsTest.cs @@ -623,4 +623,93 @@ private ITaskItem CreateFrameworkAsset(string filePath, string sourceId, string ["LastWriteTime"] = new DateTimeOffset(new DateTime(1990, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc)).ToString(StaticWebAsset.DateTimeAssetFormat), }); } + + private ITaskItem CreateEndpoint(string route, string assetFile, string label = null) + { + var properties = label != null + ? $$"""[{"Name":"label","Value":"{{label}}"}]""" + : "[]"; + + return new TaskItem(route, new Dictionary + { + ["Route"] = route, + ["AssetFile"] = assetFile, + ["Selectors"] = "[]", + ["ResponseHeaders"] = "[]", + ["EndpointProperties"] = properties, + }); + } + + [Fact] + public void Execute_FrameworkAssets_RemapsEndpointRoutes_StripOldBasePath() + { + // Arrange + var sourceFile = CreateTempFile("source", "framework.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/FxLib", "js/framework.js"); + var endpoint = CreateEndpoint("_content/FxLib/js/framework.js", sourceFile, "_content/FxLib/js/framework.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = [asset], + Endpoints = [endpoint], + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerApp", + ProjectBasePath = "/", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.RemappedEndpoints.Should().HaveCount(1); + var remapped = task.RemappedEndpoints[0]; + + // Route should have old base path stripped; "/" means just relative path. + remapped.ItemSpec.Should().Be("js/framework.js"); + remapped.GetMetadata("AssetFile").Should().Contain(Path.Combine("fx", "FxLib")); + + // Label should also be remapped. + remapped.GetMetadata("EndpointProperties").Should().Contain("js/framework.js"); + remapped.GetMetadata("EndpointProperties").Should().NotContain("_content/FxLib"); + + // Original endpoints should be output for removal. + task.OriginalFrameworkEndpoints.Should().HaveCount(1); + task.OriginalFrameworkEndpoints[0].ItemSpec.Should().Be("_content/FxLib/js/framework.js"); + } + + [Fact] + public void Execute_FrameworkAssets_RemapsEndpointRoutes_ToConsumerBasePath() + { + // Arrange + var sourceFile = CreateTempFile("source2", "lib.js", "content"); + var asset = CreateFrameworkAsset(sourceFile, "FxLib", "_content/FxLib", "js/lib.js"); + var endpoint = CreateEndpoint("_content/FxLib/js/lib.js", sourceFile, "_content/FxLib/js/lib.js"); + + var task = new UpdatePackageStaticWebAssets + { + BuildEngine = _buildEngine.Object, + Assets = [asset], + Endpoints = [endpoint], + IntermediateOutputPath = Path.Combine(_tempDir, "obj"), + ProjectPackageId = "ConsumerLib", + ProjectBasePath = "_content/ConsumerLib", + }; + + // Act + var result = task.Execute(); + + // Assert + result.Should().BeTrue(); + task.RemappedEndpoints.Should().HaveCount(1); + var remapped = task.RemappedEndpoints[0]; + + // Route should use consumer's base path. + remapped.ItemSpec.Should().Be("_content/ConsumerLib/js/lib.js"); + + // Label should also reflect consumer's base path. + remapped.GetMetadata("EndpointProperties").Should().Contain("_content/ConsumerLib/js/lib.js"); + remapped.GetMetadata("EndpointProperties").Should().NotContain("_content/FxLib"); + } }