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)); +}