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
new file mode 100644
index 000000000000..01c38d591d91
--- /dev/null
+++ b/src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs
@@ -0,0 +1,209 @@
+// 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.Runtime.CompilerServices;
+using System.Text;
+using Xunit.Abstractions;
+
+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(ITestOutputHelper output, bool isolateNuGetFeeds = true, [CallerMemberName] string testName = "")
+ {
+ _output = output;
+ _id = $"{testName}-{Guid.NewGuid():N}";
+ _root = Path.Combine(StaticWebAssetsTestData.ArtifactsTmpDir, "ComponentsWebViewStaticWebAssetsTests", _id);
+ 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"), "");
+
+ 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
+ // 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);
+ }
+
+ /// 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,
+ 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");
+
+ _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); } } };
+ 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 { }
+ _preserve = true;
+ throw new TimeoutException($"'dotnet {verb}' timed out. Binlog: {binlogPath}\n{output}");
+ }
+
+ process.WaitForExit();
+ 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);
+ }
+ 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, string BinlogPath)
+{
+ 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..6209414b22dd
--- /dev/null
+++ b/src/Components/WebView/test/StaticWebAssets/Microsoft.AspNetCore.Components.WebView.StaticWebAssets.Tests.csproj
@@ -0,0 +1,65 @@
+
+
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ enable
+ false
+
+
+
+
+
+
+
+
+
+ <_Parameter1>ArtifactsShippingPackagesDir
+ <_Parameter2>$(ArtifactsShippingPackagesDir)
+
+
+ <_Parameter1>ArtifactsNonShippingPackagesDir
+ <_Parameter2>$(ArtifactsNonShippingPackagesDir)
+
+
+ <_Parameter1>StaticWebAssetsTestPackageVersion
+ <_Parameter2>$(PackageVersion)
+
+
+ <_Parameter1>RepoRoot
+ <_Parameter2>$(RepoRoot)
+
+
+ <_Parameter1>NuGetPackageRoot
+ <_Parameter2>$(NuGetPackageRoot)
+
+
+ <_Parameter1>ArtifactsTmpDir
+ <_Parameter2>$(ArtifactsTmpDir)
+
+
+ <_Parameter1>ArtifactsLogDir
+ <_Parameter2>$(ArtifactsLogDir)
+
+
+ <_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..20c4a1c08b0f
--- /dev/null
+++ b/src/Components/WebView/test/StaticWebAssets/PackageLayoutTests.cs
@@ -0,0 +1,193 @@
+// 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_ShipsStaticWebAssets()
+ {
+ using var package = PackageArchive.Open(WebViewPackageId);
+
+ Assert.True(package.HasEntry("staticwebassets/blazor.webview.js"),
+ "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_ShipsBlazorModulesJsonAsRawEmptyFallback()
+ {
+ using var package = PackageArchive.Open(WebViewPackageId);
+ using var manifest = package.ReadPackageAssetsManifest();
+
+ // 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));
+
+ // 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_ModelsBlazorWebViewJsAsPackageAsset()
+ {
+ using var package = PackageArchive.Open(WebViewPackageId);
+ using var manifest = package.ReadPackageAssetsManifest();
+
+ var js = GetAsset(manifest, "blazor.webview.js");
+
+ Assert.Equal("Package", js.GetProperty("SourceType").GetString());
+ Assert.Equal("_framework", js.GetProperty("BasePath").GetString());
+ }
+
+ [ConditionalFact]
+ public void WebViewPackage_ServesWebViewJsAtFrameworkRoute()
+ {
+ 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.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]
+ 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.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..ee8fed27b699
--- /dev/null
+++ b/src/Components/WebView/test/StaticWebAssets/StaticWebAssetsTestData.cs
@@ -0,0 +1,103 @@
+// 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");
+
+ ///
+ /// 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");
+
+ ///
+ /// 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 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");
+
+ ///
+ /// 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..5b740d90ea60
--- /dev/null
+++ b/src/Components/WebView/test/StaticWebAssets/WebViewBuildBehaviorTests.cs
@@ -0,0 +1,204 @@
+// 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;
+using Xunit.Abstractions;
+
+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 readonly ITestOutputHelper _output;
+
+ public WebViewBuildBehaviorTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private static string AppProject => $"""
+
+
+ {StaticWebAssetsTestData.DefaultTargetFramework}
+ Exe
+ enable
+
+
+
+
+
+ """;
+
+ [ConditionalFact]
+ public void Publish_AppReferencingRclWithJsModules_ProducesSingleModulesManifest()
+ {
+ 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", $"""
+
+
+ {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 feed failures only.
+ 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 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!);
+ Assert.Contains("_content/rcl/", publishedContent);
+ Assert.Contains(".lib.module.js", publishedContent);
+ }
+
+ [ConditionalFact]
+ 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("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.\n{result.Output}");
+ Assert.DoesNotContain("Conflicting assets with the same target path", result.Output);
+
+ // 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]
+ public void Publish_ProjectReferenceToWebViewWithJsModuleRcl_SucceedsWithSingleModulesManifest()
+ {
+ // 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 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", $"""
+
+
+ {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 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!);
+ 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")
+ ?? 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));
+}