Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 11 additions & 39 deletions src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
}
Expand All @@ -105,26 +104,29 @@ public override bool Execute()
private bool ResolveAssetsAndEndpoints(
List<StaticWebAsset> assets,
List<StaticWebAssetEndpoint> endpoints,
string sourceId,
string packageRoot,
string contentRoot)
{
var fxDir = Path.Combine(IntermediateOutputPath, "fx", sourceId);
var frameworkPaths = new Dictionary<string, string>(OSPath.PathComparer);
var normalizedContentRoot = StaticWebAsset.NormalizeContentRootPath(contentRoot);

foreach (var asset in assets)
{
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
{
Expand Down Expand Up @@ -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)));
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<Library>) 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\<originalSourceId> (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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>$(AspNetTestTfm)</TargetFramework>
</PropertyGroup>

<PropertyGroup>
<UseRazorBuildServer>false</UseRazorBuildServer>
</PropertyGroup>

<Target Name="EnsurePackagesExist" BeforeTargets="Restore">
<ItemGroup>
<LocalPackages Include="$(AspNetTestPackageSource)\GroupedFrameworkPackage.1.0.0.nupkg" />
</ItemGroup>

<Error Condition="!Exists('%(LocalPackages.Identity)')"
Text="Package %(LocalPackages.Identity) does not exist. Ensure the test calls CreatePackCommand(ProjectDirectory, &quot;GroupedFrameworkPackage&quot;)" />

<Error Condition="'$(RestorePackagesPath)' != '$(AspNetNugetIsolationPath)'"
Text="Packages are not being restored into the expected directory. Ensure that NUGET_PACKAGES and AspNetNugetIsolationPath are set as environment variables. The test should extend IsolatedNuGetPackageFolderAspNetSdkBaselineTest. RestorePackagesPath = '$(RestorePackagesPath)', AspNetNugetIsolationPath = '$(AspNetNugetIsolationPath)'" />
</Target>

<ItemGroup>
<ProjectReference Include="..\GroupedFrameworkLibrary\GroupedFrameworkLibrary.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<TargetFramework>$(AspNetTestTfm)</TargetFramework>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<DeterministicSourcePaths>false</DeterministicSourcePaths>
</PropertyGroup>

<PropertyGroup>
<UseRazorBuildServer>false</UseRazorBuildServer>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="GroupedFrameworkPackage" Version="1.0.0" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div>library content</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<TargetFramework>$(AspNetTestTfm)</TargetFramework>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<Copyright>© Microsoft</Copyright>
<Product>Grouped Framework Assets Test</Product>
<Company>Microsoft</Company>
<Description>Package shipping an asset that is both a framework asset and a group member (mirrors blazor.webassembly.js)</Description>
<DeterministicSourcePaths>false</DeterministicSourcePaths>

<!-- Mark all .js assets as Framework. Combined with the conditional group below this produces
an asset that is *both* framework and grouped, the shape that regressed blazor.webassembly.js. -->
<StaticWebAssetFrameworkPattern>**/*.js</StaticWebAssetFrameworkPattern>

<PackageOutputPath>$(AspNetTestPackageSource)</PackageOutputPath>
<PackageVersion>1.0.0</PackageVersion>
</PropertyGroup>

<PropertyGroup>
<UseRazorBuildServer>false</UseRazorBuildServer>
</PropertyGroup>

<ItemGroup>
<StaticWebAssetGroupDefinition Include="GroupedFramework" Value="enabled" Order="0" SourceId="$(PackageId)" IncludePattern="**" RelativePathPattern="**" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<Import Project="StaticWebAssets.Groups.targets" />

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>
<!-- Group membership is opt-in: only consumers that set IncludeGroupedFrameworkAssets=true
include the grouped (framework) asset. This lets the test validate conditional inclusion. -->
<ItemGroup Condition="'$(IncludeGroupedFrameworkAssets)' == 'true'">
<StaticWebAssetGroup Include="GroupedFramework" Value="enabled" SourceId="GroupedFrameworkPackage" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Framework bootstrapper script (grouped + framework asset, mirrors blazor.webassembly.js).
export function start() { }
Loading