diff --git a/src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs b/src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs index eede445e8e8b..ce5c5f598b63 100644 --- a/src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs @@ -46,7 +46,6 @@ public override bool Execute() foreach (var manifestItem in PackageManifests) { var manifestPath = manifestItem.ItemSpec; - var sourceId = manifestItem.GetMetadata("SourceId"); var packageRoot = manifestItem.GetMetadata("PackageRoot"); var contentRoot = manifestItem.GetMetadata("ContentRoot"); @@ -83,7 +82,7 @@ public override bool Execute() var endpointGroups = StaticWebAssetEndpointGroup.CreateEndpointGroups(manifest.Endpoints ?? []); var (_, includedEndpoints) = StaticWebAssetEndpointGroup.ComputeFilteredEndpoints(endpointGroups, excludedPaths); - if (!ResolveAssetsAndEndpoints(includedAssets, includedEndpoints, sourceId, packageRoot, contentRoot)) + if (!ResolveAssetsAndEndpoints(includedAssets, includedEndpoints, packageRoot, contentRoot)) { return false; } @@ -105,11 +104,9 @@ public override bool Execute() private bool ResolveAssetsAndEndpoints( List assets, List endpoints, - string sourceId, string packageRoot, string contentRoot) { - var fxDir = Path.Combine(IntermediateOutputPath, "fx", sourceId); var frameworkPaths = new Dictionary(OSPath.PathComparer); var normalizedContentRoot = StaticWebAsset.NormalizeContentRootPath(contentRoot); @@ -117,14 +114,19 @@ private bool ResolveAssetsAndEndpoints( { if (StaticWebAsset.SourceTypes.IsFramework(asset.SourceType)) { - var resolvedRelativePath = StaticWebAssetPathPattern.PathWithoutTokens(asset.RelativePath); - var destPath = Path.GetFullPath(Path.Combine(fxDir, resolvedRelativePath)); - frameworkPaths[asset.Identity] = destPath; - MaterializeFrameworkAsset(asset, packageRoot, fxDir, destPath); - if (Log.HasLoggedErrors) + // Materialize framework assets into the fx intermediate folder using the shared + // routine so they are transformed identically across all consumption paths + // (package manifest, P2P, and project). This clears AssetGroups so endpoint + // generation does not skip the materialized asset, and normalizes it. + var originalIdentity = asset.Identity; + asset.Identity = ResolvePath(packageRoot, asset.Identity); + var (materialized, _, _) = StaticWebAsset.MaterializeFrameworkAsset( + asset, IntermediateOutputPath, ProjectPackageId, ProjectBasePath, Log); + if (materialized is null || Log.HasLoggedErrors) { return false; } + frameworkPaths[originalIdentity] = materialized.Identity; } else { @@ -187,36 +189,6 @@ private StaticWebAssetPackageManifest ReadManifest(string manifestPath) return manifest; } - private void MaterializeFrameworkAsset( - StaticWebAsset asset, - string packageRoot, - string fxDir, - string destPath) - { - var sourcePath = ResolvePath(packageRoot, asset.Identity); - - if (!File.Exists(sourcePath)) - { - Log.LogError("Source file '{0}' does not exist for framework asset materialization.", sourcePath); - return; - } - - Directory.CreateDirectory(Path.GetDirectoryName(destPath)); - - if (!File.Exists(destPath) || File.GetLastWriteTimeUtc(sourcePath) > File.GetLastWriteTimeUtc(destPath)) - { - File.Copy(sourcePath, destPath, overwrite: true); - } - - asset.Identity = destPath; - asset.OriginalItemSpec = destPath; - asset.SourceType = StaticWebAsset.SourceTypes.Discovered; - asset.SourceId = ProjectPackageId; - asset.ContentRoot = StaticWebAsset.NormalizeContentRootPath(fxDir); - asset.BasePath = ProjectBasePath; - asset.AssetMode = StaticWebAsset.AssetModes.CurrentProject; - } - private static string ResolvePath(string packageRoot, string relativePath) { return Path.GetFullPath(Path.Combine(packageRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/GroupedFrameworkAssetsIntegrationTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/GroupedFrameworkAssetsIntegrationTest.cs new file mode 100644 index 000000000000..a1f9ce7d7aa4 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/GroupedFrameworkAssetsIntegrationTest.cs @@ -0,0 +1,110 @@ +// 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 GroupedFrameworkAssetsIntegrationTest(ITestOutputHelper log) + : IsolatedNuGetPackageFolderAspNetSdkBaselineTest(log, nameof(GroupedFrameworkAssetsIntegrationTest)) + { + // Regression coverage for the blazor.webassembly.js 404. A package ships an asset that is both a + // framework asset and a member of a group (as Microsoft.AspNetCore.Components.WebAssembly does for + // blazor.webassembly.js). The scenario is Package -> Library -> App: + // * The group is opt-in via the IncludeGroupedFrameworkAssets property set by the library, so + // inclusion is conditional. + // * When the library enables the group, the framework asset materializes into the library under the + // library base path (_content/) with its AssetGroups cleared. If AssetGroups is not + // cleared during materialization, downstream endpoint generation skips the asset and it 404s. + // * The materialized framework asset is a current-project asset of the library, so the app (which + // does not enable the group) does not include it at the root. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Build_PackageToLibraryToApp_GroupedFrameworkAsset_IsConditionalAndMaterializedIntoLibrary(bool includeGroupedFrameworkAssets) + { + ProjectDirectory = CreateAspNetSdkTestAsset("GroupedFrameworkAssetsSample", identifier: includeGroupedFrameworkAssets.ToString()) + .WithProjectChanges((path, document) => + { + if (Path.GetFileName(path) == "GroupedFrameworkLibrary.csproj") + { + // Only the library opts into the group, so the app does not re-include the asset. + var propertyGroup = document.Root.Descendants("TargetFramework").First().Parent; + propertyGroup.Add( + new XElement("IncludeGroupedFrameworkAssets", includeGroupedFrameworkAssets.ToString())); + } + }); + + var pack = CreatePackCommand(ProjectDirectory, "GroupedFrameworkPackage"); + ExecuteCommand(pack).Should().Pass(); + ClearCachedPackage("groupedframeworkpackage"); + + var build = CreateBuildCommand(ProjectDirectory, "GroupedFrameworkApp"); + ExecuteCommand(build).Should().Pass(); + + var libraryManifest = LoadBuildManifest( + Path.Combine(ProjectDirectory.TestRoot, "GroupedFrameworkLibrary", "obj", "Debug", DefaultTfm)); + var appManifest = LoadBuildManifest(build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString()); + + // The materialized asset lives under fx\ (the originating framework + // package), but is owned by the consuming library (SourceId, BasePath are remapped). + var libraryMaterializedJs = libraryManifest.Assets + .Where(a => a.RelativePath.Contains(".js") + && a.Identity.Contains(Path.Combine("fx", "GroupedFrameworkPackage"))) + .ToList(); + + if (includeGroupedFrameworkAssets) + { + libraryMaterializedJs.Should().NotBeEmpty( + "the grouped JS framework asset should be materialized into the library when the group is enabled"); + + foreach (var asset in libraryMaterializedJs) + { + // Materialized into the library, under the library base path. + asset.SourceId.Should().Be("GroupedFrameworkLibrary", + $"materialized framework asset {asset.RelativePath} should belong to the library"); + asset.BasePath.Should().Be("_content/GroupedFrameworkLibrary", + $"materialized framework asset {asset.RelativePath} should be under the library base path"); + + // AssetGroups must be cleared, otherwise endpoint generation skips the asset (the 404). + asset.AssetGroups.Should().BeNullOrEmpty( + $"materialized framework asset {asset.RelativePath} must have its AssetGroups cleared"); + } + + // The framework asset is a current-project asset of the library, so the app does not + // include it at the root. + appManifest.Assets + .Where(a => a.RelativePath.Contains("feature") && a.BasePath == "/") + .Should().BeEmpty("the app should not include the library's framework asset at the root"); + } + else + { + // The group is not enabled, so the grouped framework asset is excluded entirely. + libraryMaterializedJs.Should().BeEmpty( + "the grouped JS framework asset should be excluded when the group is not enabled"); + } + } + + private static StaticWebAssetsManifest LoadBuildManifest(string intermediateOutputPath) + { + var path = Path.Combine(intermediateOutputPath, "staticwebassets.build.json"); + new FileInfo(path).Should().Exist(); + var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(path)); + manifest.Should().NotBeNull(); + return manifest; + } + + // Clear the cached package so NuGet re-extracts from the freshly-packed nupkg. + private void ClearCachedPackage(string packageId) + { + var cached = Path.Combine(GetNuGetCachePath(), packageId); + if (Directory.Exists(cached)) + { + Directory.Delete(cached, recursive: true); + } + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ReadPackageAssetsManifestTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ReadPackageAssetsManifestTest.cs index 1bc26855d0a1..1ce7486112fb 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ReadPackageAssetsManifestTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ReadPackageAssetsManifestTest.cs @@ -232,6 +232,31 @@ public void FrameworkAssets_MaterializedToIntermediateDirectory() emitted.GetMetadata("SourceId").Should().Be("ConsumerApp"); } + [Fact] + public void GroupedFrameworkAsset_HasAssetGroupsClearedAfterMaterialization() + { + // A framework asset that belongs to a group (e.g. blazor.webassembly.js in the + // BlazorWebAssembly group) passes group filtering when the group is declared, but + // must have its AssetGroups cleared once it is materialized as a current-project + // asset. Otherwise downstream endpoint generation treats it as a still-grouped + // asset, skips it, and the fingerprinted asset 404s at runtime. + var packageRoot = SetupPackageRoot("Microsoft.AspNetCore.Components.WebAssembly", + CreateManifestAsset("staticwebassets/_framework/blazor.webassembly.js", "_framework/blazor.webassembly.js", "/", "BlazorWebAssembly=enabled", "Framework")); + + var manifestItem = CreateManifestItem(packageRoot, "Microsoft.AspNetCore.Components.WebAssembly"); + + var task = CreateReadManifestTask( + new[] { manifestItem }, + new[] { CreateGroup("BlazorWebAssembly", "enabled", "Microsoft.AspNetCore.Components.WebAssembly") }); + task.Execute().Should().BeTrue(); + + task.Assets.Should().HaveCount(1); + + var emitted = task.Assets[0]; + emitted.GetMetadata("SourceType").Should().Be("Discovered"); + emitted.GetMetadata("AssetGroups").Should().BeEmpty("materialized framework assets must not retain group membership or endpoint generation will skip them"); + } + [Fact] public void InvalidManifestVersion_LogsError() { diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/GroupedFrameworkApp.csproj b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/GroupedFrameworkApp.csproj new file mode 100644 index 000000000000..cb7453b55eb3 --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/GroupedFrameworkApp.csproj @@ -0,0 +1,27 @@ + + + + $(AspNetTestTfm) + + + + false + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/Program.cs b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/Program.cs new file mode 100644 index 000000000000..0441593deead --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkApp/Program.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Builder; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapStaticAssets(); +app.MapGet("/", () => "Hello World!"); +app.Run(); diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/GroupedFrameworkLibrary.csproj b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/GroupedFrameworkLibrary.csproj new file mode 100644 index 000000000000..d1e73bcd2732 --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/GroupedFrameworkLibrary.csproj @@ -0,0 +1,17 @@ + + + + $(AspNetTestTfm) + true + false + + + + false + + + + + + + diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/wwwroot/library-asset.txt b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/wwwroot/library-asset.txt new file mode 100644 index 000000000000..fac4e09056d4 --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkLibrary/wwwroot/library-asset.txt @@ -0,0 +1 @@ +
library content
diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/GroupedFrameworkPackage.csproj b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/GroupedFrameworkPackage.csproj new file mode 100644 index 000000000000..301fc5bc4620 --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/GroupedFrameworkPackage.csproj @@ -0,0 +1,34 @@ + + + + $(AspNetTestTfm) + true + © Microsoft + Grouped Framework Assets Test + Microsoft + Package shipping an asset that is both a framework asset and a group member (mirrors blazor.webassembly.js) + false + + + **/*.js + + $(AspNetTestPackageSource) + 1.0.0 + + + + false + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/StaticWebAssets.Groups.targets b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/StaticWebAssets.Groups.targets new file mode 100644 index 000000000000..f130ef02633e --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/StaticWebAssets.Groups.targets @@ -0,0 +1,7 @@ + + + + + + diff --git a/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/wwwroot/feature.js b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/wwwroot/feature.js new file mode 100644 index 000000000000..e08c9d05f45e --- /dev/null +++ b/test/TestAssets/TestProjects/GroupedFrameworkAssetsSample/GroupedFrameworkPackage/wwwroot/feature.js @@ -0,0 +1,2 @@ +// Framework bootstrapper script (grouped + framework asset, mirrors blazor.webassembly.js). +export function start() { }