diff --git a/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs b/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs index 648e897c8008..5adf2d6bb705 100644 --- a/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs +++ b/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs @@ -79,5 +79,7 @@ public static void Log(this ILogger logger, LogEvent<(TArg1 public static readonly LogEvent ManifestFileNotFound = Create(LogLevel.Debug, "Manifest file '{0}' not found."); public static readonly LogEvent ProjectSpecifiesCapabilities = Create(LogLevel.Debug, "Project specifies capabilities: {0}."); public static readonly LogEvent<(Version, string)> UsingCapabilitiesBasedOnTargetFrameworkVersion = Create<(Version, string)>(LogLevel.Debug, "Using capabilities based on project target framework version: '{0}': {1}."); + public static readonly LogEvent StaticWebAssetManifestNotFound = Create(LogLevel.Warning, "Static web asset manifest not found."); + public static readonly LogEvent ScopedCssBundleFileNotFound = Create(LogLevel.Warning, "Scoped CSS bundle file '{0}' not found."); } diff --git a/src/Dotnet.Watch/Watch/Build/ProjectInstanceId.cs b/src/Dotnet.Watch/HotReloadClient/ProjectInstanceId.cs similarity index 80% rename from src/Dotnet.Watch/Watch/Build/ProjectInstanceId.cs rename to src/Dotnet.Watch/HotReloadClient/ProjectInstanceId.cs index 6eefc9921582..1b96ed08d0c7 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectInstanceId.cs +++ b/src/Dotnet.Watch/HotReloadClient/ProjectInstanceId.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Watch; +#nullable enable + +namespace Microsoft.DotNet.HotReload; internal readonly record struct ProjectInstanceId(string ProjectPath, string TargetFramework); diff --git a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAsset.cs b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAsset.cs index 37d9154b0e5d..8ccfa362f912 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAsset.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAsset.cs @@ -10,6 +10,8 @@ namespace Microsoft.DotNet.HotReload; internal readonly struct StaticWebAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) { + private static readonly StringComparison OSSpecificPathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + public string FilePath => filePath; public string RelativeUrl => relativeUrl; public string AssemblyName => assemblyName; @@ -19,8 +21,10 @@ internal readonly struct StaticWebAsset(string filePath, string relativeUrl, str public const string ManifestFileName = "staticwebassets.development.json"; public static bool IsScopedCssFile(string filePath) - => filePath.EndsWith(".razor.css", StringComparison.Ordinal) || - filePath.EndsWith(".cshtml.css", StringComparison.Ordinal); + // The extension is case-insensitive: + // https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/DiscoverDefaultScopedCssItems.cs#L36 + => filePath.EndsWith(".razor.css", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".cshtml.css", StringComparison.OrdinalIgnoreCase); public static string GetScopedCssRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath) => WebRoot + "/" + GetScopedCssBundleFileName(applicationProjectFilePath, containingProjectFilePath); @@ -29,17 +33,22 @@ public static string GetScopedCssBundleFileName(string applicationProjectFilePat { var sourceProjectName = Path.GetFileNameWithoutExtension(containingProjectFilePath); - return string.Equals(containingProjectFilePath, applicationProjectFilePath, StringComparison.OrdinalIgnoreCase) + return string.Equals(containingProjectFilePath, applicationProjectFilePath, OSSpecificPathComparison) ? $"{sourceProjectName}.styles.css" : $"{sourceProjectName}.bundle.scp.css"; } public static bool IsScopedCssBundleFile(string filePath) - => filePath.EndsWith(".bundle.scp.css", StringComparison.Ordinal) || - filePath.EndsWith(".styles.css", StringComparison.Ordinal); + // The extension is case-insensitive: + // https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/ScopedCss/ResolveAllScopedCssAssets.cs#L35 + => filePath.EndsWith(".bundle.scp.css", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".styles.css", StringComparison.OrdinalIgnoreCase); public static bool IsCompressedAssetFile(string filePath) - => filePath.EndsWith(".gz", StringComparison.Ordinal); + // The extension is case-insensitive: + // https://github.com/dotnet/sdk/blob/e62d6a2bd7172fdccd1125bfd5703290ac1b32df/src/StaticWebAssetsSdk/Tasks/Compression/DiscoverPrecompressedAssets.cs#L87 + => filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".br", StringComparison.OrdinalIgnoreCase); public static string? GetRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath, string assetFilePath) => IsScopedCssFile(assetFilePath) @@ -59,4 +68,11 @@ public static bool IsCompressedAssetFile(string filePath) ? assetFilePath.Substring(webRootDir.Length - webRoot.Length).Replace("\\", "/") : null; } + + /// + /// Assets with "_content/" prefix live outside the app's  wwwroot. + /// ASP.NET Core's convention for static assets contributed by external sources — Razor Class Libraries (RCLs) and NuGet packages. + /// + public static bool IsExternalContentUrl(string url) + => url.StartsWith("_content/", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetUpdateBuilder.cs b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetUpdateBuilder.cs new file mode 100644 index 000000000000..e621a106f368 --- /dev/null +++ b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetUpdateBuilder.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DotNet.HotReload; + +internal abstract class StaticWebAssetUpdateBuilder +{ + public readonly struct ProjectInstanceInfo + { + public required ProjectInstanceId Id { get; init; } + public required bool HasScopedCssTargets { get; init; } + public required string AssemblyName { get; init; } + } + + private readonly Dictionary> _assets = []; + private readonly HashSet _projectInstancesToRegenerate = []; + + public IReadOnlyDictionary> Assets => _assets; + +#if NET + public IReadOnlySet ProjectInstancesToRegenerate => _projectInstancesToRegenerate; +#else + public IReadOnlyCollection ProjectInstancesToRegenerate => _projectInstancesToRegenerate; +#endif + + protected abstract bool TryGetManifest(ProjectInstanceId id, [NotNullWhen(true)] out StaticWebAssetsManifest? manifest); + protected abstract IEnumerable GetProjectInstances(string projectPath); + protected abstract IEnumerable<(ProjectInstanceInfo info, ILogger logger)> GetApplicationProjectAncestors(ProjectInstanceId projectInstanceId); + + public void AddAssets( + string filePath, + IEnumerable containingProjectPaths, + string? staticWebAssetRelativeUrl) + { + var isScopedCss = StaticWebAsset.IsScopedCssFile(filePath); + if (!isScopedCss && staticWebAssetRelativeUrl is null) + { + return; + } + + foreach (var containingProjectPath in containingProjectPaths) + { + foreach (var containingProjectInstanceInfo in GetProjectInstances(containingProjectPath)) + { + if (isScopedCss) + { + if (!containingProjectInstanceInfo.HasScopedCssTargets) + { + continue; + } + + _projectInstancesToRegenerate.Add(containingProjectInstanceInfo.Id); + } + + // Enumerate running application projects that transitively reference the project containing the changed asset. + // + // For regular asset files contained in Razor Class Libraries we could expect containingProjectPaths to have all the application projects that transitively reference the RCL + // based on the asset manifest (these assets have _content-prefixed URLs). However, scoped CSS files in the RCLs are bundled into a single asset file. + // They are not individually listed in the manifest so we can't map each individual asset's file path to its containing project based on the manifest. + // + // Instead, we only track files directly contained in their own project (Web App or RCL), i.e. the asset's containing project is only the RCL project, + // and then enumerate the web application projects that transitively reference the containing project here. + foreach (var (applicationProjectInstanceInfo, applicationProjectLogger) in GetApplicationProjectAncestors(containingProjectInstanceInfo.Id)) + { + string relativeUrl; + + if (isScopedCss) + { + // Razor class library may be referenced by application that does not have static assets: + if (!applicationProjectInstanceInfo.HasScopedCssTargets) + { + continue; + } + + _projectInstancesToRegenerate.Add(applicationProjectInstanceInfo.Id); + + var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName( + applicationProjectFilePath: applicationProjectInstanceInfo.Id.ProjectPath, + containingProjectFilePath: containingProjectInstanceInfo.Id.ProjectPath); + + if (!TryGetManifest(applicationProjectInstanceInfo.Id, out var manifest)) + { + // Shouldn't happen. + applicationProjectLogger.Log(LogEvents.StaticWebAssetManifestNotFound); + continue; + } + + if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath)) + { + // Shouldn't happen. + applicationProjectLogger.Log(LogEvents.ScopedCssBundleFileNotFound, bundleFileName); + continue; + } + + filePath = bundleFilePath; + relativeUrl = bundleFileName; + } + else if (TryGetManifest(applicationProjectInstanceInfo.Id, out var _)) + { + Debug.Assert(staticWebAssetRelativeUrl != null); + relativeUrl = staticWebAssetRelativeUrl; + } + else + { + // only refresh static web assets for web application projects (e.g. not for Aspire host app that references a web project) + continue; + } + + if (!_assets.TryGetValue(applicationProjectInstanceInfo.Id, out var applicationAssets)) + { + applicationAssets = []; + _assets.Add(applicationProjectInstanceInfo.Id, applicationAssets); + } + else if (applicationAssets.ContainsKey(filePath)) + { + // asset already being updated in this application project: + continue; + } + + applicationAssets.Add(filePath, new StaticWebAsset( + filePath, + StaticWebAsset.WebRoot + "/" + relativeUrl, + containingProjectInstanceInfo.AssemblyName, + isApplicationProject: containingProjectInstanceInfo.Id == applicationProjectInstanceInfo.Id)); + } + } + } + } +} diff --git a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs index 64cfcc41bfa5..988340b3b921 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -49,12 +49,34 @@ private sealed class PatternJson /// /// Maps relative URLs to file system paths. /// - public ImmutableDictionary UrlToPathMap { get; } = urlToPathMap; + public ImmutableDictionary UrlToPathMap => urlToPathMap; /// /// List of directory and search pattern pairs for discovering static web assets. /// - public ImmutableArray DiscoveryPatterns { get; } = discoveryPatterns; + public ImmutableArray DiscoveryPatterns => discoveryPatterns; + + /// + /// Enumerates all the static web assets in the manifest of a project to be watched for changes. + /// + public IEnumerable<(string filePath, string relativeUrl)> GetFilesToWatch() + { + foreach (var entry in urlToPathMap) + { + var url = entry.Key; + var filePath = entry.Value; + + // Exclude bundle files as they are regenarated when scoped CSS files are updated. + // Exclude compressed asset files as they are not directly editable. + // Exclude external content URLs as they are not directly included in the project. + if (!StaticWebAsset.IsCompressedAssetFile(filePath) && + !StaticWebAsset.IsScopedCssBundleFile(filePath) && + !StaticWebAsset.IsExternalContentUrl(url)) + { + yield return (filePath, url); + } + } + } public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out string? filePath) { diff --git a/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs b/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs index ebf115ff3c02..1b54a61b3867 100644 --- a/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs +++ b/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs @@ -183,13 +183,9 @@ private static void ProcessBuildResults( { staticWebAssetManifestsBuilder.Add(projectInstance.GetId(), manifest); - // watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated: - foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap) + foreach (var (filePath, relativeUrl) in manifest.GetFilesToWatch()) { - if (!StaticWebAsset.IsCompressedAssetFile(filePath) && !StaticWebAsset.IsScopedCssBundleFile(filePath)) - { - AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl); - } + AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl); } } diff --git a/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs b/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs index 3378e758bb2b..7d790a0a46ee 100644 --- a/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs +++ b/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs @@ -3,6 +3,7 @@ using Microsoft.Build.Evaluation; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; @@ -40,6 +41,9 @@ public IReadOnlyList GetProjectNodes(string projectPath) return []; } + public ProjectGraphNode GetProjectNode(ProjectInstanceId projectId) + => _innerBuildNodes[projectId.ProjectPath].Single(n => n.ProjectInstance.GetTargetFramework() == projectId.TargetFramework); + public ProjectGraphNode? TryGetProjectNode(string projectPath, string? targetFramework) { var projectNodes = GetProjectNodes(projectPath); diff --git a/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs b/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs index cc2d135e2845..04bee4f00b6a 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs +++ b/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs @@ -4,6 +4,7 @@ using System.Runtime.Versioning; using Microsoft.Build.Execution; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; namespace Microsoft.DotNet.Watch; @@ -57,8 +58,8 @@ public static bool IsWebApp(this ProjectGraphNode projectNode) public static string? GetOutputDirectory(this ProjectInstance project) => project.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(project.Directory, path)) : null; - public static string GetAssemblyName(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); + public static string GetAssemblyName(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetName); public static string? GetIntermediateOutputDirectory(this ProjectInstance project) => project.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(project.Directory, path) : null; diff --git a/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.StaticWebAssets.cs b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.StaticWebAssets.cs new file mode 100644 index 000000000000..8dfb65d790b0 --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.StaticWebAssets.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed partial class ProjectUpdatesBuilder +{ + private static readonly ImmutableArray s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; + + private sealed class AssetsBuilder(ProjectUpdatesBuilder projectUpdatesBuilder, EvaluationResult evaluationResult) : StaticWebAssetUpdateBuilder + { + protected override bool TryGetManifest(ProjectInstanceId id, [NotNullWhen(true)] out StaticWebAssetsManifest? manifest) + => evaluationResult.StaticWebAssetsManifests.TryGetValue(id, out manifest); + + protected override IEnumerable GetProjectInstances(string projectPath) + => evaluationResult.ProjectGraph.GetProjectNodes(projectPath).Select(GetInfo); + + protected override IEnumerable<(ProjectInstanceInfo info, ILogger logger)> GetApplicationProjectAncestors(ProjectInstanceId projectInstanceId) + => from node in evaluationResult.ProjectGraph.GetProjectNode(projectInstanceId).GetAncestorsAndSelf() + // use any of the running projects associated with the ancestor: + let runningProject = projectUpdatesBuilder.GetCorrespondingRunningProjects(node.ProjectInstance.GetId()).FirstOrDefault() + where runningProject != null + select (GetInfo(node), runningProject.ClientLogger); + + private static ProjectInstanceInfo GetInfo(ProjectGraphNode projectNode) + => new() + { + Id = projectNode.ProjectInstance.GetId(), + AssemblyName = projectNode.ProjectInstance.GetAssemblyName(), + HasScopedCssTargets = HasScopedCssTargets(projectNode.ProjectInstance), + }; + + private static bool HasScopedCssTargets(ProjectInstance projectInstance) + => s_targets.All(projectInstance.Targets.ContainsKey); + } + + public async ValueTask AddStaticAssetUpdatesAsync( + IReadOnlyList files, + EvaluationResult evaluationResult, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var builder = new AssetsBuilder(this, evaluationResult); + + foreach (var file in files) + { + builder.AddAssets(file.Item.FilePath, file.Item.ContainingProjectPaths, file.Item.StaticWebAssetRelativeUrl); + } + + if (builder.Assets.Count == 0) + { + return; + } + + HashSet? failedApplicationProjectInstances = null; + if (builder.ProjectInstancesToRegenerate.Count > 0) + { + Logger.LogDebug("Regenerating scoped CSS bundles."); + + // Deep copy instances so that we don't pollute the project graph: + var buildRequests = builder.ProjectInstancesToRegenerate + .Select(instanceId => BuildRequest.Create(evaluationResult.RestoredProjectInstances[instanceId].DeepCopy(), s_targets)) + .ToArray(); + + _ = await evaluationResult.BuildManager.BuildAsync( + buildRequests, + onFailure: failedInstance => + { + Logger.LogWarning("[{ProjectName}] Failed to regenerate scoped CSS bundle.", failedInstance.GetDisplayName()); + + failedApplicationProjectInstances ??= []; + failedApplicationProjectInstances.Add(failedInstance.GetId()); + + // continue build + return true; + }, + operationName: "ScopedCss", + cancellationToken); + } + + foreach (var (applicationProjectInstance, instanceAssets) in builder.Assets) + { + if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true) + { + continue; + } + + foreach (var runningProject in GetCorrespondingRunningProjects(applicationProjectInstance)) + { + if (!_staticAssetUpdates.TryGetValue(runningProject, out var updatesPerRunningProject)) + { + _staticAssetUpdates.Add(runningProject, updatesPerRunningProject = []); + } + + if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported) + { + // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled). + _projectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); + _projectsToRestart.Add(runningProject); + } + else + { + updatesPerRunningProject.AddRange(instanceAssets.Values); + } + } + } + } + + private IEnumerable GetCorrespondingRunningProjects(ProjectInstanceId project) + { + if (!RunningProjects.TryGetValue(project.ProjectPath, out var projectsWithPath)) + { + return []; + } + + return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), project.TargetFramework, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs index 0f480ba23e93..07ef40d68347 100644 --- a/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs +++ b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs @@ -12,7 +12,7 @@ namespace Microsoft.DotNet.Watch; -internal sealed class ProjectUpdatesBuilder +internal sealed partial class ProjectUpdatesBuilder { public required ILogger Logger { get; init; } public required HotReloadService HotReloadService { get; init; } @@ -183,174 +183,6 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic) } } - private static readonly ImmutableArray s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; - - private static bool HasScopedCssTargets(ProjectInstance projectInstance) - => s_targets.All(projectInstance.Targets.ContainsKey); - - public async ValueTask AddStaticAssetUpdatesAsync( - IReadOnlyList files, - EvaluationResult evaluationResult, - Stopwatch stopwatch, - CancellationToken cancellationToken) - { - var assets = new Dictionary>(); - var projectInstancesToRegenerate = new HashSet(); - - foreach (var changedFile in files) - { - var file = changedFile.Item; - var isScopedCss = StaticWebAsset.IsScopedCssFile(file.FilePath); - - if (!isScopedCss && file.StaticWebAssetRelativeUrl is null) - { - continue; - } - - foreach (var containingProjectPath in file.ContainingProjectPaths) - { - foreach (var containingProjectNode in evaluationResult.ProjectGraph.GetProjectNodes(containingProjectPath)) - { - if (isScopedCss) - { - if (!HasScopedCssTargets(containingProjectNode.ProjectInstance)) - { - continue; - } - - projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance.GetId()); - } - - foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) - { - var applicationProjectInstance = referencingProjectNode.ProjectInstance; - var runningApplicationProject = GetCorrespondingRunningProjects(applicationProjectInstance).FirstOrDefault(); - if (runningApplicationProject == null) - { - continue; - } - - string filePath; - string relativeUrl; - - if (isScopedCss) - { - // Razor class library may be referenced by application that does not have static assets: - if (!HasScopedCssTargets(applicationProjectInstance)) - { - continue; - } - - projectInstancesToRegenerate.Add(applicationProjectInstance.GetId()); - - var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName( - applicationProjectFilePath: applicationProjectInstance.FullPath, - containingProjectFilePath: containingProjectNode.ProjectInstance.FullPath); - - if (!evaluationResult.StaticWebAssetsManifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) - { - // Shouldn't happen. - runningApplicationProject.ClientLogger.Log(MessageDescriptor.StaticWebAssetManifestNotFound); - continue; - } - - if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath)) - { - // Shouldn't happen. - runningApplicationProject.ClientLogger.Log(MessageDescriptor.ScopedCssBundleFileNotFound, bundleFileName); - continue; - } - - filePath = bundleFilePath; - relativeUrl = bundleFileName; - } - else - { - Debug.Assert(file.StaticWebAssetRelativeUrl != null); - filePath = file.FilePath; - relativeUrl = file.StaticWebAssetRelativeUrl; - } - - if (!assets.TryGetValue(applicationProjectInstance, out var applicationAssets)) - { - applicationAssets = []; - assets.Add(applicationProjectInstance, applicationAssets); - } - else if (applicationAssets.ContainsKey(filePath)) - { - // asset already being updated in this application project: - continue; - } - - applicationAssets.Add(filePath, new StaticWebAsset( - filePath, - StaticWebAsset.WebRoot + "/" + relativeUrl, - containingProjectNode.GetAssemblyName(), - isApplicationProject: containingProjectNode.ProjectInstance == applicationProjectInstance)); - } - } - } - } - - if (assets.Count == 0) - { - return; - } - - HashSet? failedApplicationProjectInstances = null; - if (projectInstancesToRegenerate.Count > 0) - { - Logger.LogDebug("Regenerating scoped CSS bundles."); - - // Deep copy instances so that we don't pollute the project graph: - var buildRequests = projectInstancesToRegenerate - .Select(instanceId => BuildRequest.Create(evaluationResult.RestoredProjectInstances[instanceId].DeepCopy(), s_targets)) - .ToArray(); - - _ = await evaluationResult.BuildManager.BuildAsync( - buildRequests, - onFailure: failedInstance => - { - Logger.LogWarning("[{ProjectName}] Failed to regenerate scoped CSS bundle.", failedInstance.GetDisplayName()); - - failedApplicationProjectInstances ??= []; - failedApplicationProjectInstances.Add(failedInstance); - - // continue build - return true; - }, - operationName: "ScopedCss", - cancellationToken); - } - - foreach (var (applicationProjectInstance, instanceAssets) in assets) - { - if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true) - { - continue; - } - - foreach (var runningProject in GetCorrespondingRunningProjects(applicationProjectInstance)) - { - if (!_staticAssetUpdates.TryGetValue(runningProject, out var updatesPerRunningProject)) - { - _staticAssetUpdates.Add(runningProject, updatesPerRunningProject = []); - } - - if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported) - { - // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled). - _projectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); - _projectsToRestart.Add(runningProject); - } - else - { - updatesPerRunningProject.AddRange(instanceAssets.Values); - } - } - } - } - private IEnumerable GetCorrespondingRunningProjects(Project project) { if (project.FilePath == null || !RunningProjects.TryGetValue(project.FilePath, out var projectsWithPath)) @@ -369,17 +201,6 @@ private IEnumerable GetCorrespondingRunningProjects(Project proj return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); } - private IEnumerable GetCorrespondingRunningProjects(ProjectInstance project) - { - if (!RunningProjects.TryGetValue(project.FullPath, out var projectsWithPath)) - { - return []; - } - - var tfm = project.GetTargetFramework(); - return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); - } - private ProjectInstance GetProjectInstance(Project project) { Debug.Assert(project.FilePath != null); diff --git a/src/Dotnet.Watch/Watch/UI/IReporter.cs b/src/Dotnet.Watch/Watch/UI/IReporter.cs index cf1bdafb13a1..b705bf5939b2 100644 --- a/src/Dotnet.Watch/Watch/UI/IReporter.cs +++ b/src/Dotnet.Watch/Watch/UI/IReporter.cs @@ -199,8 +199,8 @@ public static MessageDescriptor GetDescriptor(EventId id) public static readonly MessageDescriptor<(string, string, int)> LaunchedProcess = Create<(string, string, int)>("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, LogLevel.Debug); public static readonly MessageDescriptor ManagedCodeChangesApplied = Create("C# and Razor changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor StaticAssetsChangesApplied = Create("Static asset changes applied in {0}ms.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor StaticWebAssetManifestNotFound = Create("Static web asset manifest not found.", Emoji.Warning, LogLevel.Warning); - public static readonly MessageDescriptor ScopedCssBundleFileNotFound = Create("Scoped CSS bundle file '{0}' not found.", Emoji.Warning, LogLevel.Warning); + public static readonly MessageDescriptor StaticWebAssetManifestNotFound = Create(LogEvents.StaticWebAssetManifestNotFound, Emoji.Warning); + public static readonly MessageDescriptor ScopedCssBundleFileNotFound = Create(LogEvents.ScopedCssBundleFileNotFound, Emoji.Warning); public static readonly MessageDescriptor> ChangesAppliedToProjectsNotification = CreateNotification>(); public static readonly MessageDescriptor SendingUpdateBatch = Create(LogEvents.SendingUpdateBatch, Emoji.HotReload); public static readonly MessageDescriptor UpdateBatchCompleted = Create(LogEvents.UpdateBatchCompleted, Emoji.HotReload); diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetTests.cs new file mode 100644 index 000000000000..860ff65efede --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.HotReload.UnitTests; + +public class StaticWebAssetTests +{ + [Theory] + [InlineData("file.razor.css", true)] + [InlineData("file.RAZOR.CSS", true)] + [InlineData("file.Razor.Css", true)] + [InlineData("file.cshtml.css", true)] + [InlineData("file.CSHTML.CSS", true)] + [InlineData("file.Cshtml.Css", true)] + [InlineData("file.css", false)] + [InlineData("file.razor.scss", false)] + [InlineData("file.cshtml.scss", false)] + [InlineData("razor.css", false)] + [InlineData("cshtml.css", false)] + public void IsScopedCssFile_ValidatesCorrectly(string filePath, bool expected) + { + Assert.Equal(expected, StaticWebAsset.IsScopedCssFile(filePath)); + } + + [Theory] + [InlineData("file.bundle.scp.css", true)] + [InlineData("file.BUNDLE.SCP.CSS", true)] + [InlineData("file.Bundle.Scp.Css", true)] + [InlineData("file.styles.css", true)] + [InlineData("file.STYLES.CSS", true)] + [InlineData("file.Styles.Css", true)] + [InlineData("file.css", false)] + [InlineData("file.bundle.css", false)] + [InlineData("file.scp.css", false)] + [InlineData("bundle.scp.css", false)] + [InlineData("styles.css", false)] + public void IsScopedCssBundleFile_ValidatesCorrectly(string filePath, bool expected) + { + Assert.Equal(expected, StaticWebAsset.IsScopedCssBundleFile(filePath)); + } + + [Theory] + [InlineData("file.gz", true)] + [InlineData("file.GZ", true)] + [InlineData("file.Gz", true)] + [InlineData("file.br", true)] + [InlineData("file.BR", true)] + [InlineData("file.Br", true)] + [InlineData("file.zip", false)] + [InlineData("file.tar.gz", true)] + [InlineData("file.tar", false)] + [InlineData("gz", false)] + [InlineData("br", false)] + public void IsCompressedAssetFile_ValidatesCorrectly(string filePath, bool expected) + { + Assert.Equal(expected, StaticWebAsset.IsCompressedAssetFile(filePath)); + } + + [PlatformSpecificTheory(TestPlatforms.AnyUnix)] + [InlineData("MyApp.csproj", "MyApp.csproj", "MyApp.styles.css")] + [InlineData("MyApp.csproj", "MYAPP.CSPROJ", "MYAPP.bundle.scp.css")] + [InlineData("MyApp.csproj", "myapp.csproj", "myapp.bundle.scp.css")] + [InlineData("MyApp.csproj", "OtherProject.csproj", "OtherProject.bundle.scp.css")] + [InlineData("MyApp.csproj", "MyLibrary.csproj", "MyLibrary.bundle.scp.css")] + public void GetScopedCssBundleFileName_GeneratesCorrectName_Linux(string appProject, string containingProject, string expected) + { + var result = StaticWebAsset.GetScopedCssBundleFileName(appProject, containingProject); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData("MyApp.csproj", "MyApp.csproj", "MyApp.styles.css")] + [InlineData("MyApp.csproj", "MYAPP.CSPROJ", "MYAPP.styles.css")] + [InlineData("MyApp.csproj", "myapp.csproj", "myapp.styles.css")] + [InlineData("MyApp.csproj", "OtherProject.csproj", "OtherProject.bundle.scp.css")] + [InlineData("MyApp.csproj", "MyLibrary.csproj", "MyLibrary.bundle.scp.css")] + public void GetScopedCssBundleFileName_GeneratesCorrectName_Windows(string appProject, string containingProject, string expected) + { + var result = StaticWebAsset.GetScopedCssBundleFileName(appProject, containingProject); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("MyApp.csproj", "MyApp.csproj", "wwwroot/MyApp.styles.css")] + [InlineData("MyApp.csproj", "OtherProject.csproj", "wwwroot/OtherProject.bundle.scp.css")] + [InlineData("MyApp.csproj", "MyLibrary.csproj", "wwwroot/MyLibrary.bundle.scp.css")] + public void GetScopedCssRelativeUrl_GeneratesCorrectUrl(string appProject, string containingProject, string expected) + { + var result = StaticWebAsset.GetScopedCssRelativeUrl(appProject, containingProject); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\css\site.css", "wwwroot/css/site.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\js\app.js", "wwwroot/js/app.js")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\favicon.ico", "wwwroot/favicon.ico")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\lib\bootstrap\bootstrap.min.css", "wwwroot/lib/bootstrap/bootstrap.min.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\css\site.css", null)] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\OtherApp\wwwroot\css\site.css", null)] + public void GetAppRelativeUrlFomDiskPath_Windows_GeneratesCorrectUrl(string projectPath, string assetPath, string? expected) + { + var result = StaticWebAsset.GetAppRelativeUrlFomDiskPath(projectPath, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.AnyUnix)] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/wwwroot/css/site.css", "wwwroot/css/site.css")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/wwwroot/js/app.js", "wwwroot/js/app.js")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/wwwroot/favicon.ico", "wwwroot/favicon.ico")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/css/site.css", null)] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/OtherApp/wwwroot/css/site.css", null)] + public void GetAppRelativeUrlFomDiskPath_Unix_GeneratesCorrectUrl(string projectPath, string assetPath, string? expected) + { + var result = StaticWebAsset.GetAppRelativeUrlFomDiskPath(projectPath, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\Pages\Counter.razor.css", "wwwroot/MyApp.styles.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyLibrary\MyLibrary.csproj", @"C:\Projects\MyLibrary\Components\Component.razor.css", "wwwroot/MyLibrary.bundle.scp.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\css\site.css", "wwwroot/css/site.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\css\site.css", null)] + public void GetRelativeUrl_Windows_GeneratesCorrectUrl(string appProject, string containingProject, string assetPath, string? expected) + { + var result = StaticWebAsset.GetRelativeUrl(appProject, containingProject, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.AnyUnix)] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/Pages/Counter.razor.css", "wwwroot/MyApp.styles.css")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyLibrary/MyLibrary.csproj", "/home/user/MyLibrary/Components/Component.razor.css", "wwwroot/MyLibrary.bundle.scp.css")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/wwwroot/css/site.css", "wwwroot/css/site.css")] + [InlineData("/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/MyApp.csproj", "/home/user/MyApp/css/site.css", null)] + public void GetRelativeUrl_Unix_GeneratesCorrectUrl(string appProject, string containingProject, string assetPath, string? expected) + { + var result = StaticWebAsset.GetRelativeUrl(appProject, containingProject, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData(@"C:\Projects\MYAPP\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\css\site.css", "wwwroot/css/site.css")] + public void GetAppRelativeUrlFomDiskPath_IsCaseInsensitive(string projectPath, string assetPath, string? expected) + { + var result = StaticWebAsset.GetAppRelativeUrlFomDiskPath(projectPath, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\WWWROOT\css\site.css", "WWWROOT/css/site.css")] + public void GetAppRelativeUrlFomDiskPath_WwwrootIsCaseInsensitive(string projectPath, string assetPath, string? expected) + { + var result = StaticWebAsset.GetAppRelativeUrlFomDiskPath(projectPath, assetPath); + Assert.Equal(expected, result); + } + + [PlatformSpecificTheory(TestPlatforms.Windows)] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\css\site.css")] + [InlineData(@"C:\Projects\MyApp\MyApp.csproj", @"C:\Projects\MyApp\wwwroot\CSS\site.css")] + public void GetAppRelativeUrlFomDiskPath_NormalizesBackslashesToForwardSlashes(string projectPath, string assetPath) + { + var result = StaticWebAsset.GetAppRelativeUrlFomDiskPath(projectPath, assetPath); + Assert.NotNull(result); + Assert.DoesNotContain("\\", result); + Assert.Contains("/", result); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetUpdateBuilderTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetUpdateBuilderTests.cs new file mode 100644 index 000000000000..62146c6b6a70 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetUpdateBuilderTests.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.DotNet.Watch.UnitTests; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload.UnitTests; + +public class StaticWebAssetUpdateBuilderTests(ITestOutputHelper testOutput) +{ + /// + /// In-memory model of a project graph used to drive . + /// + private sealed class TestUpdateBuilder(ILogger logger) : StaticWebAssetUpdateBuilder + { + private readonly Dictionary _idsByName = []; + private readonly Dictionary> _instancesByPath = []; + private readonly Dictionary _infos = []; + private readonly Dictionary _manifests = []; + + // child instance -> the instances that directly reference it (its "parents"/ancestors) + private readonly Dictionary> _referencingProjects = []; + + // instances that have a corresponding running (launched) project + private readonly HashSet _running = []; + + public void AddProject(string name, bool running, bool hasScopedCssTargets, StaticWebAssetsManifest? manifest = null) + { + var id = new ProjectInstanceId(ProjPath(name), Tfm); + var info = new ProjectInstanceInfo + { + Id = id, + AssemblyName = name, + HasScopedCssTargets = hasScopedCssTargets, + }; + + _idsByName.Add(name, id); + _infos.Add(id, info); + _instancesByPath.Add(id.ProjectPath, [info]); + _referencingProjects.Add(id, []); + + if (running) + { + _running.Add(id); + } + + if (manifest != null) + { + _manifests.Add(id, manifest); + } + } + + public void AddReference(string referencingProject, string referencedProject) + => _referencingProjects[_idsByName[referencedProject]].Add(_idsByName[referencingProject]); + + protected override bool TryGetManifest(ProjectInstanceId id, [NotNullWhen(true)] out StaticWebAssetsManifest? manifest) + { + if (_manifests.TryGetValue(id, out var found)) + { + manifest = found; + return true; + } + + manifest = null; + return false; + } + + protected override IEnumerable GetProjectInstances(string projectPath) + => _instancesByPath.TryGetValue(projectPath, out var list) ? list : []; + + protected override IEnumerable<(ProjectInstanceInfo info, ILogger logger)> GetApplicationProjectAncestors(ProjectInstanceId projectInstanceId) + { + foreach (var ancestor in GetAncestorsAndSelf(projectInstanceId)) + { + if (_running.Contains(ancestor)) + { + yield return (_infos[ancestor], logger); + } + } + + IEnumerable GetAncestorsAndSelf(ProjectInstanceId id) + { + var visited = new HashSet { id }; + var queue = new Queue(); + queue.Enqueue(id); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + yield return current; + + foreach (var referrer in _referencingProjects[current]) + { + if (visited.Add(referrer)) + { + queue.Enqueue(referrer); + } + } + } + } + } + } + + private const string Tfm = "net10.0"; + + private static readonly string s_root = Path.Combine(Path.GetTempPath(), "StaticWebAssetUpdateBuilderTests"); + + private static string ProjDir(string name) + => Path.Combine(s_root, name); + + private static string ProjPath(string name) + => Path.Combine(ProjDir(name), name + ".csproj"); + + private static string Asset(string projName, params string[] relativeSegments) + => Path.Combine([ProjDir(projName), .. relativeSegments]); + + // Path of the scoped CSS bundle that a web application generates for the styles of a referenced project. + private static string Bundle(string appName, string containingProjectName) + => Path.Combine(ProjDir(appName), "obj", containingProjectName + ".bundle.scp.css"); + + private static StaticWebAssetsManifest Manifest(params (string url, string path)[] entries) + => new(entries.ToImmutableDictionary(e => e.url, e => e.path, StringComparer.Ordinal), discoveryPatterns: []); + + + private static string Inspect(StaticWebAsset asset) + => $"{asset.FilePath} | {asset.RelativeUrl} | {asset.AssemblyName} | app={asset.IsApplicationProject}"; + + private static void VerifyAssets(StaticWebAssetUpdateBuilder builder, params (string appName, string[] assets)[] expected) + { + var actual = builder.Assets.ToDictionary( + entry => Path.GetFileNameWithoutExtension(entry.Key.ProjectPath), + entry => entry.Value.Values.Select(Inspect).OrderBy(x => x, StringComparer.Ordinal).ToArray()); + + AssertEx.SequenceEqual( + expected.Select(e => e.appName).OrderBy(x => x, StringComparer.Ordinal), + actual.Keys.OrderBy(x => x, StringComparer.Ordinal)); + + foreach (var (appName, assets) in expected) + { + AssertEx.SequenceEqual( + assets.OrderBy(x => x, StringComparer.Ordinal), + actual[appName], + message: appName); + } + } + + private static void AssertRegenerate(StaticWebAssetUpdateBuilder builder, params string[] expectedProjectNames) + => AssertEx.SequenceEqual( + expectedProjectNames.OrderBy(x => x, StringComparer.Ordinal), + builder.ProjectInstancesToRegenerate.Select(id => Path.GetFileNameWithoutExtension(id.ProjectPath)).OrderBy(x => x, StringComparer.Ordinal)); + + /// + /// Host (running, non-web) references WebA (running), WebB (running), WebC (not running) and Console (running, non-web). + /// Each of these projects references a single RCL that contains a static web asset and a scoped CSS file. + /// Both RCL assets are updated. + /// + /// Only running web applications (WebA, WebB) receive updates. + /// WebC is excluded because it is not running; Host and Console are excluded because they are not web applications. + /// + [Fact] + public void MultipleAppsReferencingSharedRcl() + { + var logger = new TestLogger(testOutput); + var builder = new TestUpdateBuilder(logger); + + builder.AddProject("Host", running: true, hasScopedCssTargets: false); + builder.AddProject("WebA", running: true, hasScopedCssTargets: true, manifest: Manifest(("Rcl.bundle.scp.css", Bundle("WebA", "Rcl")))); + builder.AddProject("WebB", running: true, hasScopedCssTargets: true, manifest: Manifest(("Rcl.bundle.scp.css", Bundle("WebB", "Rcl")))); + builder.AddProject("WebC", running: false, hasScopedCssTargets: true, manifest: Manifest(("Rcl.bundle.scp.css", Bundle("WebC", "Rcl")))); + builder.AddProject("Console", running: true, hasScopedCssTargets: false); + builder.AddProject("Rcl", running: false, hasScopedCssTargets: true); + + builder.AddReference("Host", "WebA"); + builder.AddReference("Host", "WebB"); + builder.AddReference("Host", "WebC"); + builder.AddReference("Host", "Console"); + builder.AddReference("WebA", "Rcl"); + builder.AddReference("WebB", "Rcl"); + builder.AddReference("WebC", "Rcl"); + builder.AddReference("Console", "Rcl"); + + // Regular static web asset contained in the RCL: + builder.AddAssets(Asset("Rcl", "wwwroot", "background.png"), [ProjPath("Rcl")], "background.png"); + + // Scoped CSS file contained in the RCL: + builder.AddAssets(Asset("Rcl", "Components", "Component.razor.css"), [ProjPath("Rcl")], staticWebAssetRelativeUrl: null); + + VerifyAssets(builder, + ("WebA", + [ + $"{Asset("Rcl", "wwwroot", "background.png")} | wwwroot/background.png | Rcl | app=False", + $"{Bundle("WebA", "Rcl")} | wwwroot/Rcl.bundle.scp.css | Rcl | app=False", + ]), + ("WebB", + [ + $"{Asset("Rcl", "wwwroot", "background.png")} | wwwroot/background.png | Rcl | app=False", + $"{Bundle("WebB", "Rcl")} | wwwroot/Rcl.bundle.scp.css | Rcl | app=False", + ])); + + AssertRegenerate(builder, "Rcl", "WebA", "WebB"); + + Assert.False(logger.HasWarning); + Assert.False(logger.HasError); + } + + /// + /// Two running web applications (WebX, WebY). WebX references RclA and WebY references RclB. + /// A single static web asset file is linked into both RCL projects and is updated once. + /// + /// Each application receives the update for the asset via the RCL it references. + /// The reported assembly name reflects the containing RCL of each ancestor path. + /// + [Fact] + public void SharedLinkedAssetAcrossSeparateRcls() + { + var logger = new TestLogger(testOutput); + var builder = new TestUpdateBuilder(logger); + + builder.AddProject("WebX", running: true, hasScopedCssTargets: true, manifest: Manifest()); + builder.AddProject("WebY", running: true, hasScopedCssTargets: true, manifest: Manifest()); + builder.AddProject("RclA", running: false, hasScopedCssTargets: true); + builder.AddProject("RclB", running: false, hasScopedCssTargets: true); + + builder.AddReference("WebX", "RclA"); + builder.AddReference("WebY", "RclB"); + + // A single file linked into both RCLs (its containing projects are RclA and RclB): + var linkedAsset = Path.Combine(s_root, "Shared", "wwwroot", "shared.js"); + builder.AddAssets(linkedAsset, [ProjPath("RclA"), ProjPath("RclB")], "shared.js"); + + VerifyAssets(builder, + ("WebX", + [ + $"{linkedAsset} | wwwroot/shared.js | RclA | app=False", + ]), + ("WebY", + [ + $"{linkedAsset} | wwwroot/shared.js | RclB | app=False", + ])); + + // No scoped CSS involved: + AssertRegenerate(builder); + + Assert.False(logger.HasWarning); + Assert.False(logger.HasError); + } + + /// + /// A running web application references a non-RCL library (MidLib), which in turn references an RCL. + /// The RCL contains an updated static web asset and an updated scoped CSS file. + /// + /// The update reaches the web application transitively through the intermediate library. + /// + [Fact] + public void TransitiveReferenceThroughNonRclLibrary() + { + var logger = new TestLogger(testOutput); + var builder = new TestUpdateBuilder(logger); + + builder.AddProject("Web", running: true, hasScopedCssTargets: true, manifest: Manifest(("Rcl.bundle.scp.css", Bundle("Web", "Rcl")))); + builder.AddProject("MidLib", running: false, hasScopedCssTargets: false); + builder.AddProject("Rcl", running: false, hasScopedCssTargets: true); + + builder.AddReference("Web", "MidLib"); + builder.AddReference("MidLib", "Rcl"); + + builder.AddAssets(Asset("Rcl", "wwwroot", "logo.svg"), [ProjPath("Rcl")], "logo.svg"); + builder.AddAssets(Asset("Rcl", "Pages", "Counter.razor.css"), [ProjPath("Rcl")], staticWebAssetRelativeUrl: null); + + VerifyAssets(builder, + ("Web", + [ + $"{Asset("Rcl", "wwwroot", "logo.svg")} | wwwroot/logo.svg | Rcl | app=False", + $"{Bundle("Web", "Rcl")} | wwwroot/Rcl.bundle.scp.css | Rcl | app=False", + ])); + + AssertRegenerate(builder, "Rcl", "Web"); + + Assert.False(logger.HasWarning); + Assert.False(logger.HasError); + } + + /// + /// A single web application references two RCL projects. + /// Each RCL contains a static web asset and a scoped CSS file. All four assets are updated. + /// + [Fact] + public void SingleAppWithTwoRcls() + { + var logger = new TestLogger(testOutput); + var builder = new TestUpdateBuilder(logger); + + builder.AddProject("Web", running: true, hasScopedCssTargets: true, manifest: Manifest( + ("Rcl1.bundle.scp.css", Bundle("Web", "Rcl1")), + ("Rcl2.bundle.scp.css", Bundle("Web", "Rcl2")))); + builder.AddProject("Rcl1", running: false, hasScopedCssTargets: true); + builder.AddProject("Rcl2", running: false, hasScopedCssTargets: true); + + builder.AddReference("Web", "Rcl1"); + builder.AddReference("Web", "Rcl2"); + + builder.AddAssets(Asset("Rcl1", "wwwroot", "a.png"), [ProjPath("Rcl1")], "a.png"); + builder.AddAssets(Asset("Rcl2", "wwwroot", "b.png"), [ProjPath("Rcl2")], "b.png"); + builder.AddAssets(Asset("Rcl1", "Component1.razor.css"), [ProjPath("Rcl1")], staticWebAssetRelativeUrl: null); + builder.AddAssets(Asset("Rcl2", "Component2.razor.css"), [ProjPath("Rcl2")], staticWebAssetRelativeUrl: null); + + VerifyAssets(builder, + ("Web", + [ + $"{Asset("Rcl1", "wwwroot", "a.png")} | wwwroot/a.png | Rcl1 | app=False", + $"{Asset("Rcl2", "wwwroot", "b.png")} | wwwroot/b.png | Rcl2 | app=False", + $"{Bundle("Web", "Rcl1")} | wwwroot/Rcl1.bundle.scp.css | Rcl1 | app=False", + $"{Bundle("Web", "Rcl2")} | wwwroot/Rcl2.bundle.scp.css | Rcl2 | app=False", + ])); + + AssertRegenerate(builder, "Rcl1", "Rcl2", "Web"); + + Assert.False(logger.HasWarning); + Assert.False(logger.HasError); + } +}