From e55164e7f62b8afa6e32058443eccb68fe5577c0 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 23 Jun 2026 02:14:17 +0200 Subject: [PATCH 1/5] [Blazor] Model WebView blazor.modules.json as a framework asset (#67374) Microsoft.AspNetCore.Components.WebView shipped its fallback blazor.modules.json via a static web asset group (BlazorWebViewModules=fallback) plus manifest-promotion targets. At publish, group filtering runs with SkipDeferred=true, so the fallback was not excluded and GenerateStaticWebAssetEndpointsManifest saw two AssetKind=All assets on _framework/blazor.modules.json, throwing 'Sequence contains more than one element' in MAUI Blazor Hybrid apps that also reference a JS-module-contributing RCL. Model blazor.modules.json as a framework static web asset (like Microsoft.AspNetCore.Components.WebAssembly ships its JS): BasePath '/', assets under wwwroot/_framework/, and StaticWebAssetFrameworkPattern '**/*.js;**/*.modules.json'. The framework pattern is matched against the fingerprinted relative path, so a suffix glob (*.modules.json) is required for the JS module manifest to be classified as a Framework asset. The deferred BlazorWebViewModules group + promotion targets are removed; a minimal StaticWebAssets.Groups.targets keeps JSModuleManifestRelativePath and CompressionEnabled for consumers. Add a test project that cracks the built .nupkg files and asserts the static web assets layout/shape for the WebView, WebAssembly, App.Internal.Assets and Identity.UI packages, plus end-to-end build/publish tests that reference the locally-built WebView package from a generated app (and a JS-module RCL) and validate the produced endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osoft.AspNetCore.Components.WebView.csproj | 39 ++-- .../src/StaticWebAssets.Groups.targets | 97 ++-------- .../_framework}/blazor.modules.json | 0 .../test/StaticWebAssets/ConsumerBuild.cs | 163 ++++++++++++++++ ...nents.WebView.StaticWebAssets.Tests.csproj | 57 ++++++ .../test/StaticWebAssets/PackageArchive.cs | 67 +++++++ .../StaticWebAssets/PackageLayoutTests.cs | 182 ++++++++++++++++++ .../RequiresBuiltPackagesAttribute.cs | 31 +++ .../StaticWebAssetsTestData.cs | 76 ++++++++ .../WebViewBuildBehaviorTests.cs | 134 +++++++++++++ 10 files changed, 740 insertions(+), 106 deletions(-) rename src/Components/WebView/WebView/src/{ => wwwroot/_framework}/blazor.modules.json (100%) create mode 100644 src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs create mode 100644 src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj create mode 100644 src/Components/WebView/test/StaticWebAssets/PackageArchive.cs create mode 100644 src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs create mode 100644 src/Components/WebView/test/StaticWebAssets/RequiresBuiltPackagesAttribute.cs create mode 100644 src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs create mode 100644 src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj index 08f0696af972..9a7083fd64d1 100644 --- a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -10,9 +10,16 @@ true annotations - /_framework - - **/*.js + + / + + **/*.js;**/*.modules.json @@ -57,30 +64,22 @@ <_BlazorWebViewJSContentRoot Condition="'$(Configuration)' == 'Release'">$(RepoRoot)src\Components\Web.JS\dist\Release - + - + - + - - - - - - - diff --git a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets index 23021ec642df..a3304c2ea323 100644 --- a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets +++ b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets @@ -1,90 +1,15 @@ + - _framework/blazor.modules.json - false + _framework/blazor.modules.json + false - - - - - - - - - - - $(GenerateStaticWebAssetsManifestDependsOn); - _TagSdkModulesManifestWithGroup - - - - - - <_SdkGeneratedModulesManifest Include="@(StaticWebAsset)" - Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSModuleManifest' and '%(StaticWebAsset.SourceType)' == 'Computed'" /> - - - - - - - BlazorWebViewModules=default - All - All - - - - - - - - $(FilterDeferredStaticWebAssetGroupsDependsOn); - _ResolveBlazorWebViewModulesGroup - - - - - - - - - - - - - - - - - - - - diff --git a/src/Components/WebView/WebView/src/blazor.modules.json b/src/Components/WebView/WebView/src/wwwroot/_framework/blazor.modules.json similarity index 100% rename from src/Components/WebView/WebView/src/blazor.modules.json rename to src/Components/WebView/WebView/src/wwwroot/_framework/blazor.modules.json diff --git a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs new file mode 100644 index 000000000000..37435b1f8f02 --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// Creates a throwaway solution on disk that references the locally-built WebView package and runs +/// the repo SDK (.dotnet) to build/publish it. Used to validate that consuming the package produces +/// the expected static web asset endpoints (issue #67374). +/// +internal sealed class ConsumerBuild : IDisposable +{ + private readonly string _root; + private readonly string _packagesFolder; + + public ConsumerBuild() + { + _root = Path.Combine(Path.GetTempPath(), "wv-swa-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _packagesFolder = Path.Combine(_root, ".nuget-packages"); + + // Isolate the build from the repo and from any other test run. + File.WriteAllText(Path.Combine(_root, "Directory.Build.props"), ""); + File.WriteAllText(Path.Combine(_root, "Directory.Build.targets"), ""); + + // Use an isolated global-packages folder so the freshly-built package under test is never + // served stale from a shared cache, while adding the repo's package cache as a read-only + // fallback so the exact transitive package versions the repo restored (which may not be + // published to public feeds yet) can still be resolved. + var repoCache = StaticWebAssetsTestData.NuGetPackageRoot.TrimEnd('\\', '/'); + File.WriteAllText(Path.Combine(_root, "NuGet.config"), $""" + + + + + + + + + + + + + + + + + """); + } + + public string Root => _root; + + public string CreateProject(string relativeDir, string fileName, string content) + { + var dir = Path.Combine(_root, relativeDir); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, fileName); + File.WriteAllText(path, content); + return path; + } + + public void CreateFile(string relativePath, string content) + { + var path = Path.Combine(_root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + public ProcessResult Run(string arguments, string projectRelativePath) + { + // The package version under test is constant (e.g. 11.0.0-dev). Make sure a previously + // extracted copy in the shared repo cache (used as a fallback folder) can't shadow the + // freshly built package; restore will then pull it from the local feed. + EvictFromFallbackCache("Microsoft.AspNetCore.Components.WebView"); + + var psi = new ProcessStartInfo(StaticWebAssetsTestData.DotNetHost) + { + Arguments = $"{arguments} \"{Path.Combine(_root, projectRelativePath)}\"", + WorkingDirectory = _root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + // Keep the build hermetic: pin the repo runtime and stop the SDK from reaching outside the + // .dotnet folder. The global-packages folder is configured via NuGet.config. + psi.Environment["DOTNET_ROOT"] = Path.Combine(StaticWebAssetsTestData.RepoRoot, ".dotnet"); + psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + psi.Environment["DOTNET_NOLOGO"] = "1"; + psi.Environment.Remove("MSBuildSDKsPath"); + + var output = new StringBuilder(); + using var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (output) { output.AppendLine(e.Data); } } }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { lock (output) { output.AppendLine(e.Data); } } }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(milliseconds: 5 * 60 * 1000)) + { + try { process.Kill(entireProcessTree: true); } catch { } + throw new TimeoutException($"'dotnet {arguments}' timed out.\n{output}"); + } + + process.WaitForExit(); + return new ProcessResult(process.ExitCode, output.ToString()); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch + { + // Best effort cleanup. + } + } + + private static void EvictFromFallbackCache(string packageId) + { + var dir = Path.Combine( + StaticWebAssetsTestData.NuGetPackageRoot, + packageId.ToLowerInvariant(), + StaticWebAssetsTestData.PackageVersion); + + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + // Best effort; if it can't be removed restore may still succeed from the local feed. + } + } +} + +internal sealed record ProcessResult(int ExitCode, string Output) +{ + public bool Succeeded => ExitCode == 0; + + /// + /// True when the failure looks like it was caused by an inability to reach the NuGet feeds rather + /// than a real build problem, so offline environments can skip instead of failing. + /// + public bool LooksLikeNetworkFailure + => !Succeeded && + (Output.Contains("Unable to load the service index", StringComparison.OrdinalIgnoreCase) || + Output.Contains("NU1301", StringComparison.OrdinalIgnoreCase) || + Output.Contains("Unable to resolve", StringComparison.OrdinalIgnoreCase) && Output.Contains("nuget", StringComparison.OrdinalIgnoreCase) || + Output.Contains("The remote name could not be resolved", StringComparison.OrdinalIgnoreCase) || + Output.Contains("No such host is known", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj b/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj new file mode 100644 index 000000000000..71657130df8c --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj @@ -0,0 +1,57 @@ + + + + + + $(DefaultNetCoreTargetFramework) + enable + false + + + + + + + + + + <_Parameter1>ArtifactsShippingPackagesDir + <_Parameter2>$(ArtifactsShippingPackagesDir) + + + <_Parameter1>ArtifactsNonShippingPackagesDir + <_Parameter2>$(ArtifactsNonShippingPackagesDir) + + + <_Parameter1>StaticWebAssetsTestPackageVersion + <_Parameter2>$(PackageVersion) + + + <_Parameter1>RepoRoot + <_Parameter2>$(RepoRoot) + + + <_Parameter1>NuGetPackageRoot + <_Parameter2>$(NuGetPackageRoot) + + + <_Parameter1>DefaultNetCoreTargetFramework + <_Parameter2>$(DefaultNetCoreTargetFramework) + + + + diff --git a/src/Components/WebView/test/StaticWebAssets/PackageArchive.cs b/src/Components/WebView/test/StaticWebAssets/PackageArchive.cs new file mode 100644 index 000000000000..37995e1a5904 --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/PackageArchive.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Compression; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// Thin wrapper over a built .nupkg that exposes its entries for layout assertions. +/// +internal sealed class PackageArchive : IDisposable +{ + private readonly ZipArchive _archive; + + private PackageArchive(ZipArchive archive, string packageId, string path) + { + _archive = archive; + PackageId = packageId; + Path = path; + EntryNames = archive.Entries.Select(e => e.FullName.Replace('\\', '/')).ToArray(); + } + + public string PackageId { get; } + + public string Path { get; } + + public IReadOnlyList EntryNames { get; } + + /// + /// Opens the package for the given id. Tests that call this should be gated with + /// so they are skipped when the package is absent. + /// + public static PackageArchive Open(string packageId) + { + var path = StaticWebAssetsTestData.TryGetPackagePath(packageId) + ?? throw new InvalidOperationException( + $"Package '{packageId}.{StaticWebAssetsTestData.PackageVersion}.nupkg' was not found under the package output folders."); + + return new PackageArchive(ZipFile.OpenRead(path), packageId, path); + } + + public bool HasEntry(string entryName) + => EntryNames.Contains(entryName.Replace('\\', '/'), StringComparer.OrdinalIgnoreCase); + + public string ReadEntry(string entryName) + { + var normalized = entryName.Replace('\\', '/'); + var entry = _archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), normalized, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Entry '{entryName}' not found in package '{PackageId}'."); + + using var reader = new StreamReader(entry.Open()); + return reader.ReadToEnd(); + } + + /// + /// Parses the SDK-generated static web assets package manifest ($(PackageId).PackageAssets.json). + /// + public JsonDocument ReadPackageAssetsManifest() + { + var entryName = $"build/{PackageId}.PackageAssets.json"; + return JsonDocument.Parse(ReadEntry(entryName)); + } + + public void Dispose() => _archive.Dispose(); +} diff --git a/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs new file mode 100644 index 000000000000..0f059a0ed23a --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.InternalTesting; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// Cracks the built .nupkg files and asserts on the static web assets layout/shape for the packages +/// that ship framework or grouped static web assets. +/// +[RequiresBuiltPackages( + "Microsoft.AspNetCore.Components.WebView", + "Microsoft.AspNetCore.Components.WebAssembly", + "Microsoft.AspNetCore.App.Internal.Assets", + "Microsoft.AspNetCore.Identity.UI")] +public class PackageLayoutTests +{ + private const string WebViewPackageId = "Microsoft.AspNetCore.Components.WebView"; + private const string WebAssemblyPackageId = "Microsoft.AspNetCore.Components.WebAssembly"; + private const string AssetsInternalPackageId = "Microsoft.AspNetCore.App.Internal.Assets"; + private const string IdentityUIPackageId = "Microsoft.AspNetCore.Identity.UI"; + + [ConditionalFact] + public void WebViewPackage_ShipsFrameworkAssetsUnderFramework() + { + using var package = PackageArchive.Open(WebViewPackageId); + + Assert.True(package.HasEntry("staticwebassets/_framework/blazor.modules.json"), + "blazor.modules.json should ship under staticwebassets/_framework/."); + Assert.True(package.HasEntry("staticwebassets/_framework/blazor.webview.js"), + "blazor.webview.js should ship under staticwebassets/_framework/."); + } + + [ConditionalFact] + public void WebViewPackage_ModelsBlazorModulesJsonAsFrameworkAsset() + { + using var package = PackageArchive.Open(WebViewPackageId); + using var manifest = package.ReadPackageAssetsManifest(); + + var modules = GetAsset(manifest, "_framework/blazor.modules.json"); + + // The whole point of the fix (#67374): blazor.modules.json must be a Framework asset so it is + // materialized into the consuming project, instead of a Package asset that collides with the + // app's own generated manifest at build/publish time. + Assert.Equal("Framework", modules.GetProperty("SourceType").GetString()); + Assert.Equal("JSModule", modules.GetProperty("AssetTraitName").GetString()); + Assert.Equal("JSModuleManifest", modules.GetProperty("AssetTraitValue").GetString()); + // No static web asset groups should be involved anymore. + Assert.True(string.IsNullOrEmpty(modules.GetProperty("AssetGroups").GetString())); + } + + [ConditionalFact] + public void WebViewPackage_ModelsBlazorWebViewJsAsFrameworkAsset() + { + using var package = PackageArchive.Open(WebViewPackageId); + using var manifest = package.ReadPackageAssetsManifest(); + + var js = GetAsset(manifest, "_framework/blazor.webview.js"); + + Assert.Equal("Framework", js.GetProperty("SourceType").GetString()); + } + + [ConditionalFact] + public void WebViewPackage_ServesModulesManifestAtFrameworkRoute() + { + using var package = PackageArchive.Open(WebViewPackageId); + using var manifest = package.ReadPackageAssetsManifest(); + + var routes = manifest.RootElement.GetProperty("Endpoints") + .EnumerateArray() + .Select(e => e.GetProperty("Route").GetString()) + .ToArray(); + + Assert.Contains("_framework/blazor.modules.json", routes); + Assert.Contains("_framework/blazor.webview.js", routes); + } + + [ConditionalFact] + public void WebViewPackage_GroupsTargetsCarriesConsumerProperties() + { + using var package = PackageArchive.Open(WebViewPackageId); + + Assert.True(package.HasEntry("build/StaticWebAssets.Groups.targets"), + "The package should ship build/StaticWebAssets.Groups.targets with consumer build properties."); + + var groups = package.ReadEntry("build/StaticWebAssets.Groups.targets"); + Assert.Contains(" + e.EndsWith(".targets", StringComparison.OrdinalIgnoreCase) || + e.EndsWith(".props", StringComparison.OrdinalIgnoreCase) || + e.EndsWith(".json", StringComparison.OrdinalIgnoreCase))) + { + var content = package.ReadEntry(entryName); + Assert.DoesNotContain("BlazorWebViewModules", content); + Assert.DoesNotContain("_TagSdkModulesManifestWithGroup", content); + Assert.DoesNotContain("_ResolveBlazorWebViewModulesGroup", content); + } + } + + [ConditionalFact] + public void WebViewPackage_BuildTargetsImportStaticWebAssetsAndGroups() + { + using var package = PackageArchive.Open(WebViewPackageId); + + var buildTargets = package.ReadEntry($"build/{WebViewPackageId}.targets"); + Assert.Contains("Microsoft.AspNetCore.StaticWebAssets.targets", buildTargets); + Assert.Contains("StaticWebAssets.Groups.targets", buildTargets); + + Assert.True(package.HasEntry($"buildTransitive/{WebViewPackageId}.targets"), + "buildTransitive targets should be present so the assets flow transitively."); + } + + [ConditionalFact] + public void WebAssemblyPackage_ShipsBlazorWebAssemblyAsFrameworkAsset() + { + using var package = PackageArchive.Open(WebAssemblyPackageId); + + Assert.True(package.HasEntry("staticwebassets/_framework/blazor.webassembly.js")); + + using var manifest = package.ReadPackageAssetsManifest(); + var js = GetAsset(manifest, "_framework/blazor.webassembly.js"); + Assert.Equal("Framework", js.GetProperty("SourceType").GetString()); + } + + [ConditionalFact] + public void AssetsInternalPackage_ShipsBlazorScriptsAsFrameworkAssets() + { + using var package = PackageArchive.Open(AssetsInternalPackageId); + + Assert.True(package.HasEntry("staticwebassets/_framework/blazor.web.js")); + Assert.True(package.HasEntry("staticwebassets/_framework/blazor.server.js")); + + using var manifest = package.ReadPackageAssetsManifest(); + Assert.Equal("Framework", GetAsset(manifest, "_framework/blazor.web.js").GetProperty("SourceType").GetString()); + Assert.Equal("Framework", GetAsset(manifest, "_framework/blazor.server.js").GetProperty("SourceType").GetString()); + } + + [ConditionalFact] + public void IdentityUIPackage_ShipsBootstrapAssetsForBothVersions() + { + using var package = PackageArchive.Open(IdentityUIPackageId); + + Assert.Contains(package.EntryNames, e => e.StartsWith("staticwebassets/V4/lib/bootstrap/", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(package.EntryNames, e => e.StartsWith("staticwebassets/V5/lib/bootstrap/", StringComparison.OrdinalIgnoreCase)); + } + + [ConditionalFact] + public void IdentityUIPackage_GroupsTargetsSelectsBootstrapVersion() + { + using var package = PackageArchive.Open(IdentityUIPackageId); + + Assert.True(package.HasEntry("build/StaticWebAssets.Groups.targets")); + var groups = package.ReadEntry("build/StaticWebAssets.Groups.targets"); + Assert.Contains("BootstrapVersion", groups); + } + + private static JsonElement GetAsset(JsonDocument manifest, string relativePathSuffix) + { + foreach (var asset in manifest.RootElement.GetProperty("Assets").EnumerateObject()) + { + if (asset.Name.Replace('\\', '/').EndsWith(relativePathSuffix, StringComparison.OrdinalIgnoreCase)) + { + return asset.Value; + } + } + + throw new InvalidOperationException($"No asset ending with '{relativePathSuffix}' found in the package manifest."); + } +} diff --git a/src/Components/WebView/test/StaticWebAssets/RequiresBuiltPackagesAttribute.cs b/src/Components/WebView/test/StaticWebAssets/RequiresBuiltPackagesAttribute.cs new file mode 100644 index 000000000000..eeafc6402fef --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/RequiresBuiltPackagesAttribute.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.InternalTesting; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// Skips a test when the required locally-built .nupkg files +/// have not been produced (for example on a fresh clone or a CI leg that does not pack), so these +/// packaging tests only run where the packages are available. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class RequiresBuiltPackagesAttribute : Attribute, ITestCondition +{ + private readonly string[] _packageIds; + + public RequiresBuiltPackagesAttribute(params string[] packageIds) + { + _packageIds = packageIds; + } + + public bool IsMet => MissingPackages.Count == 0; + + public string SkipReason => + $"Required package(s) were not built: {string.Join(", ", MissingPackages)}. " + + $"Pack the projects (e.g. './eng/build.cmd -pack') before running these tests."; + + private List MissingPackages + => _packageIds.Where(id => StaticWebAssetsTestData.TryGetPackagePath(id) is null).ToList(); +} diff --git a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs new file mode 100644 index 000000000000..c63bb662f853 --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// Resolves test-time configuration (package locations, version, repo paths) injected as assembly +/// metadata by the test project's csproj. +/// +internal static class StaticWebAssetsTestData +{ + private static readonly Dictionary Metadata = typeof(StaticWebAssetsTestData).Assembly + .GetCustomAttributes() + .Where(a => a.Value is not null) + .GroupBy(a => a.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Last().Value!, StringComparer.Ordinal); + + public static string PackageVersion => GetValue("StaticWebAssetsTestPackageVersion"); + + public static string ShippingPackagesDir => GetValue("ArtifactsShippingPackagesDir"); + + public static string NonShippingPackagesDir => GetValue("ArtifactsNonShippingPackagesDir"); + + public static string RepoRoot => GetValue("RepoRoot"); + + /// + /// The repo's global NuGet packages folder, used as a read-only fallback so consumer builds can + /// resolve the exact transitive package versions the repo restored (which may not be on public feeds). + /// + public static string NuGetPackageRoot => GetValue("NuGetPackageRoot"); + + public static string DefaultTargetFramework => GetValue("DefaultNetCoreTargetFramework"); + + /// + /// Path to the locally-built SDK host (.dotnet/dotnet[.exe]) used to run consumer builds. + /// + public static string DotNetHost + { + get + { + var fileName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + return Path.Combine(RepoRoot, ".dotnet", fileName); + } + } + + /// + /// Locates the .nupkg for the given package id (looking in the shipping and non-shipping + /// package output folders). Returns when the package was not built. + /// + public static string? TryGetPackagePath(string packageId) + { + var fileName = $"{packageId}.{PackageVersion}.nupkg"; + foreach (var dir in new[] { ShippingPackagesDir, NonShippingPackagesDir }) + { + if (string.IsNullOrEmpty(dir)) + { + continue; + } + + var candidate = Path.Combine(dir, fileName); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + private static string GetValue(string key) + => Metadata.TryGetValue(key, out var value) + ? value + : throw new InvalidOperationException($"Missing assembly metadata '{key}'. Ensure the test project injects it."); +} diff --git a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs new file mode 100644 index 000000000000..85495c49aad2 --- /dev/null +++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.InternalTesting; + +namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; + +/// +/// End-to-end build/publish tests that reference the locally-built WebView package from a generated +/// app (and an RCL contributing JS library modules) and assert on the produced static web asset +/// endpoints. These reproduce and lock in the fix for issue #67374, where publishing an app that +/// references both the WebView package and a JS-module-contributing RCL crashed with +/// "Sequence contains more than one element". +/// +[RequiresBuiltPackages("Microsoft.AspNetCore.Components.WebView")] +public class WebViewBuildBehaviorTests +{ + private static string AppProject => $""" + + + {StaticWebAssetsTestData.DefaultTargetFramework} + Exe + enable + + + + + + """; + + [ConditionalFact] + public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest() + { + using var build = new ConsumerBuild(); + + // RCL that contributes a JS library module, which makes the SDK generate a blazor.modules.json. + build.CreateProject("rcl", "rcl.csproj", $""" + + + {StaticWebAssetsTestData.DefaultTargetFramework} + + + """); + build.CreateFile("rcl/wwwroot/rcl.lib.module.js", "export function afterStarted() {}"); + + // App that references both the RCL and the WebView package (the crashing combination). + build.CreateProject("app", "app.csproj", $""" + + + {StaticWebAssetsTestData.DefaultTargetFramework} + Exe + + + + + + + """); + build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); + + var result = build.Run("publish -c Release -v:m", "app/app.csproj"); + // xUnit 2.x has no runtime skip; tolerate transient inability to reach the NuGet feeds. + if (result.LooksLikeNetworkFailure) + { + return; + } + + Assert.True(result.Succeeded, $"Publish should succeed (no 'Sequence contains more than one element').\n{result.Output}"); + Assert.DoesNotContain("Sequence contains more than one element", result.Output); + Assert.DoesNotContain("Conflicting assets with the same target path", result.Output); + + var routes = GetModulesManifestRoutes(build.Root); + Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); + + // The app's generated manifest (with the RCL module) supersedes the package fallback. The + // module filename is fingerprinted (e.g. rcl..lib.module.js), so match loosely. + var publishedManifest = FindPublishedFile(build.Root, "blazor.modules.json"); + Assert.NotNull(publishedManifest); + var publishedContent = File.ReadAllText(publishedManifest!); + Assert.Contains("_content/rcl/", publishedContent); + Assert.Contains(".lib.module.js", publishedContent); + } + + [ConditionalFact] + public void Build_AppWithoutJsModules_ServesFallbackModulesManifest() + { + using var build = new ConsumerBuild(); + + build.CreateProject("app", "app.csproj", AppProject); + build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); + + var result = build.Run("build -c Debug -v:m", "app/app.csproj"); + // xUnit 2.x has no runtime skip; tolerate transient inability to reach the NuGet feeds. + if (result.LooksLikeNetworkFailure) + { + return; + } + + Assert.True(result.Succeeded, $"Build should succeed.\n{result.Output}"); + + // With no app-provided JS modules, the materialized package fallback is served. + var routes = GetModulesManifestRoutes(build.Root); + Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); + } + + private static string[] GetModulesManifestRoutes(string root) + { + var manifestPath = FindFile(root, "app", "app.staticwebassets.endpoints.json") + ?? throw new InvalidOperationException("Could not find the app's static web assets endpoints manifest."); + + using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath)); + return doc.RootElement.GetProperty("Endpoints") + .EnumerateArray() + .Select(e => e.GetProperty("Route").GetString()!) + // Ignore fingerprinted routes (e.g. _framework/blazor..modules.json); assert on the + // stable route only. + .Where(route => route.EndsWith("blazor.modules.json", StringComparison.Ordinal) && + !IsFingerprinted(route)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsFingerprinted(string route) + => route != "_framework/blazor.modules.json"; + + private static string? FindFile(string root, string underDir, string fileName) + => Directory.EnumerateFiles(Path.Combine(root, underDir), fileName, SearchOption.AllDirectories) + .FirstOrDefault(); + + private static string? FindPublishedFile(string root, string fileName) + => Directory.EnumerateFiles(Path.Combine(root, "app"), fileName, SearchOption.AllDirectories) + .FirstOrDefault(p => p.Replace('\\', '/').Contains("/publish/", StringComparison.Ordinal)); +} From b799fc3e719f079380c992970ef7b8069032db2e Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 23 Jun 2026 14:44:04 +0200 Subject: [PATCH 2/5] Fix WebView ProjectReference publish conflict and improve packaging tests The framework-asset modeling of blazor.modules.json/blazor.webview.js broke publish for in-repo projects that reference the WebView project via ProjectReference (the WebView E2E test and the Photino sample): the SDK applies StaticWebAssetFrameworkPattern when computing a referenced project's BUILD static web assets but not its PUBLISH assets, so publish ends up with both the materialized framework asset (SourceType=Discovered, SourceId=consumer) and the original (SourceType=Project, SourceId=WebView) at the same _framework/... target path and fails with 'Conflicting assets with the same target path'. Add a no-op-for-package-consumers workaround in StaticWebAssets.Groups.targets that drops the redundant Project-sourced WebView framework assets at publish (the materialized copies are the ones served), and import the groups targets from the WebView E2E test so it (like the Photino sample) also gets JSModuleManifestRelativePath. Package consumers receive these assets as SourceType=Package, so nothing is removed for them. Test improvements: - Build-behavior tests now create working folders under artifacts/tmp instead of the system temp folder. - Each build/publish captures a binary log under artifacts/log so CI collects it and failures can be diagnosed; the working folder is preserved on failure and removed on success. - Tests log the dotnet invocation, output and binlog path via ITestOutputHelper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/StaticWebAssets.Groups.targets | 17 ++++++ ...pNetCore.Components.WebViewE2E.Test.csproj | 5 ++ .../test/StaticWebAssets/ConsumerBuild.cs | 52 ++++++++++++++++--- ...nents.WebView.StaticWebAssets.Tests.csproj | 8 +++ .../StaticWebAssetsTestData.cs | 12 +++++ .../WebViewBuildBehaviorTests.cs | 12 ++++- 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets index a3304c2ea323..0bb3d73dfe23 100644 --- a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets +++ b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets @@ -12,4 +12,21 @@ _framework/blazor.modules.json false + + + + + + + diff --git a/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj b/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj index fc4ade975e4b..9c50151a9f8d 100644 --- a/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj +++ b/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj @@ -13,6 +13,11 @@ + + + PreserveNewest diff --git a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs index 37435b1f8f02..f42f337fe778 100644 --- a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs +++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text; +using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; @@ -10,15 +12,24 @@ namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; /// Creates a throwaway solution on disk that references the locally-built WebView package and runs /// the repo SDK (.dotnet) to build/publish it. Used to validate that consuming the package produces /// the expected static web asset endpoints (issue #67374). +/// +/// Working folders live under the repo's artifacts/tmp directory (not the system temp folder), and +/// every build/publish captures a binary log under artifacts/log so failures can be diagnosed from +/// CI. The working folder is preserved when a build fails and removed on success. /// internal sealed class ConsumerBuild : IDisposable { + private readonly ITestOutputHelper _output; private readonly string _root; private readonly string _packagesFolder; + private readonly string _id; + private bool _preserve; - public ConsumerBuild() + public ConsumerBuild(ITestOutputHelper output, [CallerMemberName] string testName = "") { - _root = Path.Combine(Path.GetTempPath(), "wv-swa-tests", Guid.NewGuid().ToString("N")); + _output = output; + _id = $"{testName}-{Guid.NewGuid():N}"; + _root = Path.Combine(StaticWebAssetsTestData.ArtifactsTmpDir, "ComponentsWebViewStaticWebAssetsTests", _id); Directory.CreateDirectory(_root); _packagesFolder = Path.Combine(_root, ".nuget-packages"); @@ -69,16 +80,24 @@ public void CreateFile(string relativePath, string content) File.WriteAllText(path, content); } - public ProcessResult Run(string arguments, string projectRelativePath) + /// The dotnet verb plus its options, e.g. "publish -c Release". + /// Project to build, relative to the working folder. + public ProcessResult Run(string verb, string projectRelativePath) { // The package version under test is constant (e.g. 11.0.0-dev). Make sure a previously // extracted copy in the shared repo cache (used as a fallback folder) can't shadow the // freshly built package; restore will then pull it from the local feed. EvictFromFallbackCache("Microsoft.AspNetCore.Components.WebView"); + // Capture a binary log under artifacts/log so CI uploads it and failures can be analyzed. + var verbName = verb.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "build"; + var binlogPath = Path.Combine(StaticWebAssetsTestData.ArtifactsLogDir, $"WebViewStaticWebAssets-{_id}-{verbName}.binlog"); + Directory.CreateDirectory(StaticWebAssetsTestData.ArtifactsLogDir); + + var arguments = $"{verb} \"{Path.Combine(_root, projectRelativePath)}\" -bl:\"{binlogPath}\""; var psi = new ProcessStartInfo(StaticWebAssetsTestData.DotNetHost) { - Arguments = $"{arguments} \"{Path.Combine(_root, projectRelativePath)}\"", + Arguments = arguments, WorkingDirectory = _root, RedirectStandardOutput = true, RedirectStandardError = true, @@ -93,6 +112,8 @@ public ProcessResult Run(string arguments, string projectRelativePath) psi.Environment["DOTNET_NOLOGO"] = "1"; psi.Environment.Remove("MSBuildSDKsPath"); + _output.WriteLine($"> dotnet {arguments}"); + var output = new StringBuilder(); using var process = new Process { StartInfo = psi }; process.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (output) { output.AppendLine(e.Data); } } }; @@ -105,15 +126,32 @@ public ProcessResult Run(string arguments, string projectRelativePath) if (!process.WaitForExit(milliseconds: 5 * 60 * 1000)) { try { process.Kill(entireProcessTree: true); } catch { } - throw new TimeoutException($"'dotnet {arguments}' timed out.\n{output}"); + _preserve = true; + throw new TimeoutException($"'dotnet {verb}' timed out. Binlog: {binlogPath}\n{output}"); } process.WaitForExit(); - return new ProcessResult(process.ExitCode, output.ToString()); + var result = new ProcessResult(process.ExitCode, output.ToString(), binlogPath); + + _output.WriteLine(result.Output); + _output.WriteLine($"Exit code: {result.ExitCode}. Binlog: {binlogPath}"); + if (!result.Succeeded) + { + // Leave the working folder in place so the failure can be investigated locally. + _preserve = true; + } + + return result; } public void Dispose() { + if (_preserve) + { + _output.WriteLine($"Build failed; preserving working folder for investigation: {_root}"); + return; + } + try { Directory.Delete(_root, recursive: true); @@ -145,7 +183,7 @@ private static void EvictFromFallbackCache(string packageId) } } -internal sealed record ProcessResult(int ExitCode, string Output) +internal sealed record ProcessResult(int ExitCode, string Output, string BinlogPath) { public bool Succeeded => ExitCode == 0; diff --git a/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj b/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj index 71657130df8c..6209414b22dd 100644 --- a/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj +++ b/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj @@ -48,6 +48,14 @@ <_Parameter1>NuGetPackageRoot <_Parameter2>$(NuGetPackageRoot) + + <_Parameter1>ArtifactsTmpDir + <_Parameter2>$(ArtifactsTmpDir) + + + <_Parameter1>ArtifactsLogDir + <_Parameter2>$(ArtifactsLogDir) + <_Parameter1>DefaultNetCoreTargetFramework <_Parameter2>$(DefaultNetCoreTargetFramework) diff --git a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs index c63bb662f853..14ca8923ee01 100644 --- a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs +++ b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs @@ -31,6 +31,18 @@ internal static class StaticWebAssetsTestData /// public static string NuGetPackageRoot => GetValue("NuGetPackageRoot"); + /// + /// Root directory for throwaway build working folders (under the repo's artifacts/tmp), used + /// instead of the system temp folder so test output is colocated with other build artifacts and + /// cleaned up by the normal artifacts lifecycle. + /// + public static string ArtifactsTmpDir => GetValue("ArtifactsTmpDir"); + + /// + /// Directory where build logs (binlogs) are written so CI collects them for diagnosing failures. + /// + public static string ArtifactsLogDir => GetValue("ArtifactsLogDir"); + public static string DefaultTargetFramework => GetValue("DefaultNetCoreTargetFramework"); /// diff --git a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs index 85495c49aad2..edbe312a8bb3 100644 --- a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.AspNetCore.InternalTesting; +using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; @@ -16,6 +17,13 @@ namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets; [RequiresBuiltPackages("Microsoft.AspNetCore.Components.WebView")] public class WebViewBuildBehaviorTests { + private readonly ITestOutputHelper _output; + + public WebViewBuildBehaviorTests(ITestOutputHelper output) + { + _output = output; + } + private static string AppProject => $""" @@ -32,7 +40,7 @@ public class WebViewBuildBehaviorTests [ConditionalFact] public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest() { - using var build = new ConsumerBuild(); + using var build = new ConsumerBuild(_output); // RCL that contributes a JS library module, which makes the SDK generate a blazor.modules.json. build.CreateProject("rcl", "rcl.csproj", $""" @@ -85,7 +93,7 @@ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest [ConditionalFact] public void Build_AppWithoutJsModules_ServesFallbackModulesManifest() { - using var build = new ConsumerBuild(); + using var build = new ConsumerBuild(_output); build.CreateProject("app", "app.csproj", AppProject); build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); From e339b6d2ee5cd832f4611594a358736968c83317 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 23 Jun 2026 15:09:19 +0200 Subject: [PATCH 3/5] Add ProjectReference (P2P) publish regression test for WebView framework assets Adds Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleModulesManifest, which references the WebView source project (not the package) plus a JS-module RCL and runs 'dotnet publish'. This is the exact in-repo scenario that regressed in CI: without the StaticWebAssets.Groups.targets workaround it fails with 'Conflicting assets with the same target path _framework/blazor.modules.json'. The test asserts publish succeeds, a single _framework/blazor.modules.json endpoint is produced, and the app's generated manifest (with the RCL module) supersedes the WebView fallback. ConsumerBuild gains an isolateNuGetFeeds option so P2P builds inherit the repo NuGet.config (needed to build the referenced source project) instead of the isolated package feed used by the PackageReference tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/StaticWebAssets/ConsumerBuild.cs | 10 +++- .../StaticWebAssetsTestData.cs | 14 +++++ .../WebViewBuildBehaviorTests.cs | 56 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs index f42f337fe778..01c38d591d91 100644 --- a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs +++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs @@ -25,7 +25,7 @@ internal sealed class ConsumerBuild : IDisposable private readonly string _id; private bool _preserve; - public ConsumerBuild(ITestOutputHelper output, [CallerMemberName] string testName = "") + public ConsumerBuild(ITestOutputHelper output, bool isolateNuGetFeeds = true, [CallerMemberName] string testName = "") { _output = output; _id = $"{testName}-{Guid.NewGuid():N}"; @@ -37,6 +37,14 @@ public ConsumerBuild(ITestOutputHelper output, [CallerMemberName] string testNam File.WriteAllText(Path.Combine(_root, "Directory.Build.props"), ""); File.WriteAllText(Path.Combine(_root, "Directory.Build.targets"), ""); + if (!isolateNuGetFeeds) + { + // ProjectReference (P2P) mode: the app references the WebView source project, so it needs + // no package feed of its own. Inherit the repo's NuGet.config (the working folder lives + // under the repo's artifacts) so the referenced project's dependencies resolve. + return; + } + // Use an isolated global-packages folder so the freshly-built package under test is never // served stale from a shared cache, while adding the repo's package cache as a read-only // fallback so the exact transitive package versions the repo restored (which may not be diff --git a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs index 14ca8923ee01..5a91c765904f 100644 --- a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs +++ b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs @@ -45,6 +45,20 @@ internal static class StaticWebAssetsTestData public static string DefaultTargetFramework => GetValue("DefaultNetCoreTargetFramework"); + /// + /// Absolute path to the WebView source project, used by the ProjectReference (P2P) publish test + /// that reproduces the in-repo "Conflicting assets" publish failure. + /// + public static string WebViewProjectPath => Path.Combine( + RepoRoot, "src", "Components", "WebView", "WebView", "src", "Microsoft.AspNetCore.Components.WebView.csproj"); + + /// + /// Absolute path to the WebView consumer-side groups targets (sets JSModuleManifestRelativePath + /// and drops the redundant Project-sourced framework assets at publish for P2P consumers). + /// + public static string WebViewGroupsTargetsPath => Path.Combine( + RepoRoot, "src", "Components", "WebView", "WebView", "src", "StaticWebAssets.Groups.targets"); + /// /// Path to the locally-built SDK host (.dotnet/dotnet[.exe]) used to run consumer builds. /// diff --git a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs index edbe312a8bb3..5f201f8df87b 100644 --- a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs @@ -112,6 +112,62 @@ public void Build_AppWithoutJsModules_ServesFallbackModulesManifest() Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); } + [ConditionalFact] + public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleModulesManifest() + { + // ProjectReference (P2P) variant of the publish repro. This is the scenario that the in-repo + // Photino sample and WebView E2E test exercise: an app references the WebView *source project* + // (not the package) and contributes JS library modules via an RCL. Without the workaround in + // StaticWebAssets.Groups.targets, publish fails with "Conflicting assets with the same target + // path '_framework/blazor.modules.json'" because the SDK doesn't apply + // StaticWebAssetFrameworkPattern when computing a referenced project's publish assets. + using var build = new ConsumerBuild(_output, isolateNuGetFeeds: false); + + build.CreateProject("rcl", "rcl.csproj", $""" + + + {StaticWebAssetsTestData.DefaultTargetFramework} + + + """); + build.CreateFile("rcl/wwwroot/rcl.lib.module.js", "export function afterStarted() {}"); + + // The app imports the WebView groups targets the same way the in-repo consumers do. + build.CreateProject("app", "app.csproj", $""" + + + {StaticWebAssetsTestData.DefaultTargetFramework} + Exe + + + + + + + + """); + build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); + + var result = build.Run("publish -c Release -v:m", "app/app.csproj"); + if (result.LooksLikeNetworkFailure) + { + return; + } + + Assert.True(result.Succeeded, $"Publish should succeed (no 'Conflicting assets').\n{result.Output}"); + Assert.DoesNotContain("Conflicting assets with the same target path", result.Output); + + var routes = GetModulesManifestRoutes(build.Root); + Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); + + // The app's generated manifest (with the RCL module) supersedes the WebView fallback. + var publishedManifest = FindPublishedFile(build.Root, "blazor.modules.json"); + Assert.NotNull(publishedManifest); + var publishedContent = File.ReadAllText(publishedManifest!); + Assert.Contains("_content/rcl/", publishedContent); + Assert.Contains(".lib.module.js", publishedContent); + } + private static string[] GetModulesManifestRoutes(string root) { var manifestPath = FindFile(root, "app", "app.staticwebassets.endpoints.json") From 208c9e31890f055eda55554e3998cba0cc748ef7 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 23 Jun 2026 17:55:24 +0200 Subject: [PATCH 4/5] Use deferred static web asset group for WebView modules.json (rely on dotnet/sdk#54941) Reverts the framework-asset modeling and the in-package publish workaround. With the SDK fix in dotnet/sdk#54941 (resolved deferred static web asset groups are persisted into the build manifest and re-applied, unscoped, when the manifest is reloaded at publish), the idiomatic deferred-group authoring that the WebView package already uses is correct end-to-end: blazor.modules.json is a Package static web asset in the deferred BlazorWebViewModules group, resolved to drop the fallback when the app contributes its own JS modules and keep it otherwise. No framework-asset hack and no package-local workaround are needed, so the WebView product files are unchanged from main. This PR now contributes the static web assets packaging + build/publish regression tests: - Package-layout tests assert the deferred-group shape for WebView (modules.json = Package in BlazorWebViewModules=fallback; webview.js = Framework) and the framework/group shapes for Components.WebAssembly, App.Internal.Assets and Identity.UI. - Build/publish behavior tests (package consumer and ProjectReference consumer) assert a single _framework/blazor.modules.json endpoint and that the app's manifest supersedes the fallback. The publish assertions are skipped until dotnet/sdk#54941 is in the repo SDK (detected via the pre-fix 'Sequence contains more than one element' crash) so the suite stays green meanwhile. Note: this PR depends on dotnet/sdk#54941 flowing into the repo SDK; until then the in-repo WebView publish path (and the publish tests) require that fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osoft.AspNetCore.Components.WebView.csproj | 39 +++---- .../src/StaticWebAssets.Groups.targets | 106 ++++++++++++++---- .../_framework => }/blazor.modules.json | 0 ...pNetCore.Components.WebViewE2E.Test.csproj | 5 - .../test/StaticWebAssets/ConsumerBuild.cs | 9 ++ .../StaticWebAssets/PackageLayoutTests.cs | 52 +++++---- .../WebViewBuildBehaviorTests.cs | 18 +-- 7 files changed, 145 insertions(+), 84 deletions(-) rename src/Components/WebView/WebView/src/{wwwroot/_framework => }/blazor.modules.json (100%) diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj index 9a7083fd64d1..08f0696af972 100644 --- a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -10,16 +10,9 @@ true annotations - - / - - **/*.js;**/*.modules.json + /_framework + + **/*.js @@ -64,22 +57,30 @@ <_BlazorWebViewJSContentRoot Condition="'$(Configuration)' == 'Release'">$(RepoRoot)src\Components\Web.JS\dist\Release - + - + - + - + + + + + + diff --git a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets index 0bb3d73dfe23..23021ec642df 100644 --- a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets +++ b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets @@ -1,32 +1,90 @@ - - _framework/blazor.modules.json - false + _framework/blazor.modules.json + false - - + + + + + + + + + + $(GenerateStaticWebAssetsManifestDependsOn); + _TagSdkModulesManifestWithGroup + + + + + + <_SdkGeneratedModulesManifest Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSModuleManifest' and '%(StaticWebAsset.SourceType)' == 'Computed'" /> + + + + + + + BlazorWebViewModules=default + All + All + + + + + + + + $(FilterDeferredStaticWebAssetGroupsDependsOn); + _ResolveBlazorWebViewModulesGroup + + + + + + + + + + - + + + + + + + diff --git a/src/Components/WebView/WebView/src/wwwroot/_framework/blazor.modules.json b/src/Components/WebView/WebView/src/blazor.modules.json similarity index 100% rename from src/Components/WebView/WebView/src/wwwroot/_framework/blazor.modules.json rename to src/Components/WebView/WebView/src/blazor.modules.json diff --git a/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj b/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj index 9c50151a9f8d..fc4ade975e4b 100644 --- a/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj +++ b/src/Components/WebView/test/E2ETest/Microsoft.AspNetCore.Components.WebViewE2E.Test.csproj @@ -13,11 +13,6 @@ - - - PreserveNewest diff --git a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs index 01c38d591d91..beacb9421dce 100644 --- a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs +++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs @@ -206,4 +206,13 @@ public bool LooksLikeNetworkFailure Output.Contains("Unable to resolve", StringComparison.OrdinalIgnoreCase) && Output.Contains("nuget", StringComparison.OrdinalIgnoreCase) || Output.Contains("The remote name could not be resolved", StringComparison.OrdinalIgnoreCase) || Output.Contains("No such host is known", StringComparison.OrdinalIgnoreCase)); + + /// + /// True when publish fails with the deferred-group publish bug that dotnet/sdk#54941 fixes. The + /// WebView fallback blazor.modules.json is modeled as a deferred static web asset group; resolving + /// that group at publish requires the SDK fix. Until it flows into the repo SDK, the publish + /// assertions are skipped (rather than hard-failing) so the suite stays green. + /// + public bool RequiresDeferredPublishSdkFix + => !Succeeded && Output.Contains("Sequence contains more than one element", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs index 0f059a0ed23a..edbdf3bd56e5 100644 --- a/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs @@ -23,32 +23,32 @@ public class PackageLayoutTests private const string IdentityUIPackageId = "Microsoft.AspNetCore.Identity.UI"; [ConditionalFact] - public void WebViewPackage_ShipsFrameworkAssetsUnderFramework() + public void WebViewPackage_ShipsStaticWebAssets() { using var package = PackageArchive.Open(WebViewPackageId); - Assert.True(package.HasEntry("staticwebassets/_framework/blazor.modules.json"), - "blazor.modules.json should ship under staticwebassets/_framework/."); - Assert.True(package.HasEntry("staticwebassets/_framework/blazor.webview.js"), - "blazor.webview.js should ship under staticwebassets/_framework/."); + Assert.True(package.HasEntry("staticwebassets/blazor.modules.json"), + "blazor.modules.json should ship under staticwebassets/."); + Assert.True(package.HasEntry("staticwebassets/blazor.webview.js"), + "blazor.webview.js should ship under staticwebassets/."); } [ConditionalFact] - public void WebViewPackage_ModelsBlazorModulesJsonAsFrameworkAsset() + public void WebViewPackage_ModelsBlazorModulesJsonAsDeferredGroupedFallback() { using var package = PackageArchive.Open(WebViewPackageId); using var manifest = package.ReadPackageAssetsManifest(); - var modules = GetAsset(manifest, "_framework/blazor.modules.json"); + var modules = GetAsset(manifest, "blazor.modules.json"); - // The whole point of the fix (#67374): blazor.modules.json must be a Framework asset so it is - // materialized into the consuming project, instead of a Package asset that collides with the - // app's own generated manifest at build/publish time. - Assert.Equal("Framework", modules.GetProperty("SourceType").GetString()); + // blazor.modules.json is the fallback JS module manifest, shipped as a Package static web + // asset in the deferred BlazorWebViewModules group. At build (and, with dotnet/sdk#54941, at + // publish) the deferred group is resolved so this fallback is dropped when the app contributes + // its own JS modules, and kept otherwise. + Assert.Equal("Package", modules.GetProperty("SourceType").GetString()); Assert.Equal("JSModule", modules.GetProperty("AssetTraitName").GetString()); Assert.Equal("JSModuleManifest", modules.GetProperty("AssetTraitValue").GetString()); - // No static web asset groups should be involved anymore. - Assert.True(string.IsNullOrEmpty(modules.GetProperty("AssetGroups").GetString())); + Assert.Equal("BlazorWebViewModules=fallback", modules.GetProperty("AssetGroups").GetString()); } [ConditionalFact] @@ -57,7 +57,7 @@ public void WebViewPackage_ModelsBlazorWebViewJsAsFrameworkAsset() using var package = PackageArchive.Open(WebViewPackageId); using var manifest = package.ReadPackageAssetsManifest(); - var js = GetAsset(manifest, "_framework/blazor.webview.js"); + var js = GetAsset(manifest, "blazor.webview.js"); Assert.Equal("Framework", js.GetProperty("SourceType").GetString()); } @@ -92,22 +92,20 @@ public void WebViewPackage_GroupsTargetsCarriesConsumerProperties() } [ConditionalFact] - public void WebViewPackage_DoesNotUseBlazorWebViewModulesGroupMachinery() + public void WebViewPackage_UsesBlazorWebViewModulesDeferredGroup() { using var package = PackageArchive.Open(WebViewPackageId); - // Regression guard for #67374: the deferred BlazorWebViewModules group + manifest-promotion - // targets are what caused the publish-time crash. They must be gone from every shipped file. - foreach (var entryName in package.EntryNames.Where(e => - e.EndsWith(".targets", StringComparison.OrdinalIgnoreCase) || - e.EndsWith(".props", StringComparison.OrdinalIgnoreCase) || - e.EndsWith(".json", StringComparison.OrdinalIgnoreCase))) - { - var content = package.ReadEntry(entryName); - Assert.DoesNotContain("BlazorWebViewModules", content); - Assert.DoesNotContain("_TagSdkModulesManifestWithGroup", content); - Assert.DoesNotContain("_ResolveBlazorWebViewModulesGroup", content); - } + // The fallback manifest is selected via the deferred BlazorWebViewModules group. The package + // ships the resolution machinery in Groups.targets and tags the fallback in the manifest. + var groups = package.ReadEntry("build/StaticWebAssets.Groups.targets"); + Assert.Contains("BlazorWebViewModules", groups); + Assert.Contains("Deferred=\"true\"", groups); + Assert.Contains("_ResolveBlazorWebViewModulesGroup", groups); + Assert.Contains("_TagSdkModulesManifestWithGroup", groups); + + var manifest = package.ReadEntry($"build/{WebViewPackageId}.PackageAssets.json"); + Assert.Contains("BlazorWebViewModules=fallback", manifest); } [ConditionalFact] diff --git a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs index 5f201f8df87b..e1f3c7c71165 100644 --- a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs @@ -68,8 +68,9 @@ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); var result = build.Run("publish -c Release -v:m", "app/app.csproj"); - // xUnit 2.x has no runtime skip; tolerate transient inability to reach the NuGet feeds. - if (result.LooksLikeNetworkFailure) + // xUnit 2.x has no runtime skip; tolerate transient feed failures, and skip until the + // deferred-group publish fix (dotnet/sdk#54941) is in the repo SDK. + if (result.LooksLikeNetworkFailure || result.RequiresDeferredPublishSdkFix) { return; } @@ -115,12 +116,11 @@ public void Build_AppWithoutJsModules_ServesFallbackModulesManifest() [ConditionalFact] public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleModulesManifest() { - // ProjectReference (P2P) variant of the publish repro. This is the scenario that the in-repo - // Photino sample and WebView E2E test exercise: an app references the WebView *source project* - // (not the package) and contributes JS library modules via an RCL. Without the workaround in - // StaticWebAssets.Groups.targets, publish fails with "Conflicting assets with the same target - // path '_framework/blazor.modules.json'" because the SDK doesn't apply - // StaticWebAssetFrameworkPattern when computing a referenced project's publish assets. + // ProjectReference (P2P) variant of the publish repro. An app references the WebView *source + // project* (not the package) and contributes JS library modules via an RCL, importing the + // WebView groups targets like the in-repo Photino sample / E2E test. The deferred + // BlazorWebViewModules group must be resolved at publish (dotnet/sdk#54941) so the fallback + // and the app's generated manifest don't both survive on _framework/blazor.modules.json. using var build = new ConsumerBuild(_output, isolateNuGetFeeds: false); build.CreateProject("rcl", "rcl.csproj", $""" @@ -149,7 +149,7 @@ public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleM build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); var result = build.Run("publish -c Release -v:m", "app/app.csproj"); - if (result.LooksLikeNetworkFailure) + if (result.LooksLikeNetworkFailure || result.RequiresDeferredPublishSdkFix) { return; } From 97ac2b70a00b448d9b9a3d218fcd12e1e4babd5e Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 23 Jun 2026 22:43:05 +0200 Subject: [PATCH 5/5] Fix WebView blazor.modules.json publish crash via conditional fallback (#67374) The WebView package ships a fallback _framework/blazor.modules.json (empty []) for apps that contribute no JS library modules. Modeling it as a deferred static web asset group required tagging/promoting the consumer's SDK-generated manifest and depended on an SDK fix (dotnet/sdk#54941) to filter the group at publish; without it, publish crashed with "Sequence contains more than one element" (#67374). Replace the group authoring with conditional materialization: the package ships the fallback raw under build/ (not as a flowing static web asset) and materializes it as the consumer's own asset during ResolveStaticWebAssetsInputs ONLY when the app has no JS modules of its own. The decision runs before the build manifest / conflict check, so exactly one asset ever lands on _framework/blazor.modules.json and there is never a conflict at build or publish. No asset groups, no consumer manifest tagging, no SDK dependency. Tests validate the package layout and build/publish behavior (package + P2P consumers, with and without JS modules) by cracking the built .nupkg and running isolated consumer builds under artifacts/ with binlog capture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osoft.AspNetCore.Components.WebView.csproj | 25 ++-- .../src/StaticWebAssets.Groups.targets | 126 ++++++++---------- .../test/StaticWebAssets/ConsumerBuild.cs | 9 -- .../StaticWebAssets/PackageLayoutTests.cs | 63 +++++---- .../StaticWebAssetsTestData.cs | 5 +- .../WebViewBuildBehaviorTests.cs | 34 +++-- 6 files changed, 125 insertions(+), 137 deletions(-) diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj index 08f0696af972..614aadb9d81e 100644 --- a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -10,9 +10,9 @@ true annotations + /_framework - - **/*.js @@ -63,22 +63,15 @@ - + - - - - - - + diff --git a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets index 23021ec642df..d361b449af9c 100644 --- a/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets +++ b/src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets @@ -1,90 +1,74 @@ + + _framework/blazor.modules.json false - - - - - - - - - $(GenerateStaticWebAssetsManifestDependsOn); - _TagSdkModulesManifestWithGroup - + + $(ResolveStaticWebAssetsInputsDependsOn); + _AddBlazorWebViewModulesFallback; + + + <_AddBlazorWebViewModulesFallbackDependsOn Condition="'$(GenerateJSModuleManifest)' == 'true'">GenerateJSModuleManifestBuildStaticWebAssets - + + + <_BlazorWebViewModulesFallbackRoot>$(MSBuildThisFileDirectory) + <_BlazorWebViewModulesFallbackFile>$(_BlazorWebViewModulesFallbackRoot)blazor.modules.json + + - <_SdkGeneratedModulesManifest Include="@(StaticWebAsset)" - Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSModuleManifest' and '%(StaticWebAsset.SourceType)' == 'Computed'" /> + <_BlazorWebViewModulesFallbackCandidate + Include="$(_BlazorWebViewModulesFallbackFile)" + Condition="'@(_ExistingBuildJSModules)' == '' and Exists('$(_BlazorWebViewModulesFallbackFile)')"> + _framework/blazor.modules.json + - - - - - BlazorWebViewModules=default - All - All - - - + - - - - $(FilterDeferredStaticWebAssetGroupsDependsOn); - _ResolveBlazorWebViewModulesGroup - - + + + - - - - - + + + - - - - - - - - + + diff --git a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs index beacb9421dce..01c38d591d91 100644 --- a/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs +++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs @@ -206,13 +206,4 @@ public bool LooksLikeNetworkFailure Output.Contains("Unable to resolve", StringComparison.OrdinalIgnoreCase) && Output.Contains("nuget", StringComparison.OrdinalIgnoreCase) || Output.Contains("The remote name could not be resolved", StringComparison.OrdinalIgnoreCase) || Output.Contains("No such host is known", StringComparison.OrdinalIgnoreCase)); - - /// - /// True when publish fails with the deferred-group publish bug that dotnet/sdk#54941 fixes. The - /// WebView fallback blazor.modules.json is modeled as a deferred static web asset group; resolving - /// that group at publish requires the SDK fix. Until it flows into the repo SDK, the publish - /// assertions are skipped (rather than hard-failing) so the suite stays green. - /// - public bool RequiresDeferredPublishSdkFix - => !Succeeded && Output.Contains("Sequence contains more than one element", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs index edbdf3bd56e5..20c4a1c08b0f 100644 --- a/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs @@ -27,43 +27,49 @@ public void WebViewPackage_ShipsStaticWebAssets() { using var package = PackageArchive.Open(WebViewPackageId); - Assert.True(package.HasEntry("staticwebassets/blazor.modules.json"), - "blazor.modules.json should ship under staticwebassets/."); Assert.True(package.HasEntry("staticwebassets/blazor.webview.js"), - "blazor.webview.js should ship under staticwebassets/."); + "blazor.webview.js should ship under staticwebassets/ as a package static web asset."); + // The fallback blazor.modules.json ships raw under build/ (NOT as a static web asset), so it + // never auto-flows and never collides with the SDK-generated manifest. It is materialized + // conditionally by StaticWebAssets.Groups.targets. + Assert.True(package.HasEntry("build/blazor.modules.json"), + "blazor.modules.json should ship raw under build/."); + Assert.False(package.HasEntry("staticwebassets/blazor.modules.json"), + "blazor.modules.json should NOT ship under staticwebassets/ (it is not a static web asset)."); } [ConditionalFact] - public void WebViewPackage_ModelsBlazorModulesJsonAsDeferredGroupedFallback() + public void WebViewPackage_ShipsBlazorModulesJsonAsRawEmptyFallback() { using var package = PackageArchive.Open(WebViewPackageId); using var manifest = package.ReadPackageAssetsManifest(); - var modules = GetAsset(manifest, "blazor.modules.json"); + // blazor.modules.json is NOT a package static web asset: it is not present in the package + // assets manifest, so it does not auto-flow to consumers and cannot conflict with the + // SDK-generated _framework/blazor.modules.json when the app has its own JS modules. + Assert.DoesNotContain( + manifest.RootElement.GetProperty("Assets").EnumerateObject(), + asset => asset.Name.Replace('\\', '/').EndsWith("blazor.modules.json", StringComparison.OrdinalIgnoreCase)); - // blazor.modules.json is the fallback JS module manifest, shipped as a Package static web - // asset in the deferred BlazorWebViewModules group. At build (and, with dotnet/sdk#54941, at - // publish) the deferred group is resolved so this fallback is dropped when the app contributes - // its own JS modules, and kept otherwise. - Assert.Equal("Package", modules.GetProperty("SourceType").GetString()); - Assert.Equal("JSModule", modules.GetProperty("AssetTraitName").GetString()); - Assert.Equal("JSModuleManifest", modules.GetProperty("AssetTraitValue").GetString()); - Assert.Equal("BlazorWebViewModules=fallback", modules.GetProperty("AssetGroups").GetString()); + // The raw fallback shipped under build/ is the empty module manifest. + var fallback = package.ReadEntry("build/blazor.modules.json").Trim(); + Assert.Equal("[]", fallback); } [ConditionalFact] - public void WebViewPackage_ModelsBlazorWebViewJsAsFrameworkAsset() + public void WebViewPackage_ModelsBlazorWebViewJsAsPackageAsset() { using var package = PackageArchive.Open(WebViewPackageId); using var manifest = package.ReadPackageAssetsManifest(); var js = GetAsset(manifest, "blazor.webview.js"); - Assert.Equal("Framework", js.GetProperty("SourceType").GetString()); + Assert.Equal("Package", js.GetProperty("SourceType").GetString()); + Assert.Equal("_framework", js.GetProperty("BasePath").GetString()); } [ConditionalFact] - public void WebViewPackage_ServesModulesManifestAtFrameworkRoute() + public void WebViewPackage_ServesWebViewJsAtFrameworkRoute() { using var package = PackageArchive.Open(WebViewPackageId); using var manifest = package.ReadPackageAssetsManifest(); @@ -73,8 +79,9 @@ public void WebViewPackage_ServesModulesManifestAtFrameworkRoute() .Select(e => e.GetProperty("Route").GetString()) .ToArray(); - Assert.Contains("_framework/blazor.modules.json", routes); Assert.Contains("_framework/blazor.webview.js", routes); + // The fallback modules manifest is not a package static web asset, so it has no package endpoint. + Assert.DoesNotContain("_framework/blazor.modules.json", routes); } [ConditionalFact] @@ -92,20 +99,26 @@ public void WebViewPackage_GroupsTargetsCarriesConsumerProperties() } [ConditionalFact] - public void WebViewPackage_UsesBlazorWebViewModulesDeferredGroup() + public void WebViewPackage_MaterializesModulesFallbackConditionally() { using var package = PackageArchive.Open(WebViewPackageId); - // The fallback manifest is selected via the deferred BlazorWebViewModules group. The package - // ships the resolution machinery in Groups.targets and tags the fallback in the manifest. + // The fallback manifest is materialized as the consumer's own static web asset only when the + // app contributes no JS library modules of its own (decided before the build manifest is + // generated, so there is never a conflict). No deferred group / consumer-manifest tagging is + // involved. var groups = package.ReadEntry("build/StaticWebAssets.Groups.targets"); - Assert.Contains("BlazorWebViewModules", groups); - Assert.Contains("Deferred=\"true\"", groups); - Assert.Contains("_ResolveBlazorWebViewModulesGroup", groups); - Assert.Contains("_TagSdkModulesManifestWithGroup", groups); + Assert.Contains("_AddBlazorWebViewModulesFallback", groups); + Assert.Contains("_ExistingBuildJSModules", groups); + Assert.Contains("ResolveStaticWebAssetsInputsDependsOn", groups); + + // Lock in the simplification: no asset groups, no tagging/promotion of the SDK manifest. + Assert.DoesNotContain("Deferred=\"true\"", groups); + Assert.DoesNotContain("_TagSdkModulesManifestWithGroup", groups); + Assert.DoesNotContain("StaticWebAssetGroup", groups); var manifest = package.ReadEntry($"build/{WebViewPackageId}.PackageAssets.json"); - Assert.Contains("BlazorWebViewModules=fallback", manifest); + Assert.DoesNotContain("BlazorWebViewModules", manifest); } [ConditionalFact] diff --git a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs index 5a91c765904f..ee8fed27b699 100644 --- a/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs +++ b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs @@ -53,8 +53,9 @@ internal static class StaticWebAssetsTestData RepoRoot, "src", "Components", "WebView", "WebView", "src", "Microsoft.AspNetCore.Components.WebView.csproj"); /// - /// Absolute path to the WebView consumer-side groups targets (sets JSModuleManifestRelativePath - /// and drops the redundant Project-sourced framework assets at publish for P2P consumers). + /// Absolute path to the WebView consumer-side targets (sets JSModuleManifestRelativePath and + /// conditionally materializes the empty blazor.modules.json fallback when the app has no JS + /// library modules of its own). Imported by P2P consumers like the in-repo Photino sample. /// public static string WebViewGroupsTargetsPath => Path.Combine( RepoRoot, "src", "Components", "WebView", "WebView", "src", "StaticWebAssets.Groups.targets"); diff --git a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs index e1f3c7c71165..5b740d90ea60 100644 --- a/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs +++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs @@ -68,9 +68,8 @@ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); var result = build.Run("publish -c Release -v:m", "app/app.csproj"); - // xUnit 2.x has no runtime skip; tolerate transient feed failures, and skip until the - // deferred-group publish fix (dotnet/sdk#54941) is in the repo SDK. - if (result.LooksLikeNetworkFailure || result.RequiresDeferredPublishSdkFix) + // xUnit 2.x has no runtime skip; tolerate transient feed failures only. + if (result.LooksLikeNetworkFailure) { return; } @@ -82,8 +81,8 @@ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest var routes = GetModulesManifestRoutes(build.Root); Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); - // The app's generated manifest (with the RCL module) supersedes the package fallback. The - // module filename is fingerprinted (e.g. rcl..lib.module.js), so match loosely. + // The app generated its own manifest (with the RCL module), so the package never + // materialized its fallback. The module filename is fingerprinted, so match loosely. var publishedManifest = FindPublishedFile(build.Root, "blazor.modules.json"); Assert.NotNull(publishedManifest); var publishedContent = File.ReadAllText(publishedManifest!); @@ -92,25 +91,31 @@ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest } [ConditionalFact] - public void Build_AppWithoutJsModules_ServesFallbackModulesManifest() + public void PublishAndBuild_AppWithoutJsModules_ServesEmptyFallbackModulesManifest() { using var build = new ConsumerBuild(_output); build.CreateProject("app", "app.csproj", AppProject); build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); - var result = build.Run("build -c Debug -v:m", "app/app.csproj"); + var result = build.Run("publish -c Release -v:m", "app/app.csproj"); // xUnit 2.x has no runtime skip; tolerate transient inability to reach the NuGet feeds. if (result.LooksLikeNetworkFailure) { return; } - Assert.True(result.Succeeded, $"Build should succeed.\n{result.Output}"); + Assert.True(result.Succeeded, $"Publish should succeed.\n{result.Output}"); + Assert.DoesNotContain("Conflicting assets with the same target path", result.Output); - // With no app-provided JS modules, the materialized package fallback is served. + // With no app-provided JS modules, the package materializes its empty ([]) fallback, and it + // is the single manifest served on the route. var routes = GetModulesManifestRoutes(build.Root); Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); + + var publishedManifest = FindPublishedFile(build.Root, "blazor.modules.json"); + Assert.NotNull(publishedManifest); + Assert.Equal("[]", File.ReadAllText(publishedManifest!).Trim()); } [ConditionalFact] @@ -118,9 +123,10 @@ public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleM { // ProjectReference (P2P) variant of the publish repro. An app references the WebView *source // project* (not the package) and contributes JS library modules via an RCL, importing the - // WebView groups targets like the in-repo Photino sample / E2E test. The deferred - // BlazorWebViewModules group must be resolved at publish (dotnet/sdk#54941) so the fallback - // and the app's generated manifest don't both survive on _framework/blazor.modules.json. + // WebView StaticWebAssets.Groups.targets like the in-repo Photino sample / E2E test. Because + // the package never materializes its fallback when the app has its own modules, only the + // app's generated manifest survives on _framework/blazor.modules.json (no conflict, no SDK fix + // required). using var build = new ConsumerBuild(_output, isolateNuGetFeeds: false); build.CreateProject("rcl", "rcl.csproj", $""" @@ -149,7 +155,7 @@ public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleM build.CreateFile("app/Program.cs", "class Program { static void Main() { } }"); var result = build.Run("publish -c Release -v:m", "app/app.csproj"); - if (result.LooksLikeNetworkFailure || result.RequiresDeferredPublishSdkFix) + if (result.LooksLikeNetworkFailure) { return; } @@ -160,7 +166,7 @@ public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleM var routes = GetModulesManifestRoutes(build.Root); Assert.Equal("_framework/blazor.modules.json", Assert.Single(routes)); - // The app's generated manifest (with the RCL module) supersedes the WebView fallback. + // The app generated its own manifest (with the RCL module); the WebView fallback was not added. var publishedManifest = FindPublishedFile(build.Root, "blazor.modules.json"); Assert.NotNull(publishedManifest); var publishedContent = File.ReadAllText(publishedManifest!);