GetTransitiveProjects(IEnumerable new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework());
+ public static ProjectInstanceId GetId(this ProjectInstance project)
+ => new(project.FullPath, project.GetTargetFramework());
}
diff --git a/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs b/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs
new file mode 100644
index 000000000000..35931038b8ec
--- /dev/null
+++ b/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Build.Globbing;
+
+namespace Microsoft.DotNet.HotReload;
+
+internal sealed partial class StaticWebAssetPattern
+{
+ public MSBuildGlob Glob => field ??= MSBuildGlob.Parse(Directory, Pattern);
+}
diff --git a/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs b/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs
index 41ac7d88edea..f54743d89eef 100644
--- a/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs
+++ b/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs
@@ -13,6 +13,7 @@ public static class Names
public const string DotnetWatchIteration = "DOTNET_WATCH_ITERATION";
public const string DotnetLaunchProfile = "DOTNET_LAUNCH_PROFILE";
+ public const string DotnetHostPath = "DOTNET_HOST_PATH";
public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName;
public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks;
diff --git a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs
index 43d60c17aefe..3f5bff195e92 100644
--- a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs
+++ b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs
@@ -44,7 +44,7 @@ protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, Immu
var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories);
if (watcher is EventBasedDirectoryWatcher eventBasedWatcher)
{
- eventBasedWatcher.Logger = message => logger.LogDebug(message);
+ eventBasedWatcher.Logger = message => logger.LogTrace(message);
}
return watcher;
diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs
index e95ccc1be34f..a8f73dbb48df 100644
--- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs
+++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs
@@ -3,6 +3,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
+using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
@@ -15,9 +16,8 @@ namespace Microsoft.DotNet.Watch
internal sealed class CompilationHandler : IDisposable
{
public readonly IncrementalMSBuildWorkspace Workspace;
- private readonly ILogger _logger;
+ private readonly DotNetWatchContext _context;
private readonly HotReloadService _hotReloadService;
- private readonly ProcessRunner _processRunner;
///
/// Lock to synchronize:
@@ -39,11 +39,10 @@ internal sealed class CompilationHandler : IDisposable
private bool _isDisposed;
- public CompilationHandler(ILogger logger, ProcessRunner processRunner)
+ public CompilationHandler(DotNetWatchContext context)
{
- _logger = logger;
- _processRunner = processRunner;
- Workspace = new IncrementalMSBuildWorkspace(logger);
+ _context = context;
+ Workspace = new IncrementalMSBuildWorkspace(context.Logger);
_hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities()));
}
@@ -53,9 +52,12 @@ public void Dispose()
Workspace?.Dispose();
}
+ private ILogger Logger
+ => _context.Logger;
+
public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken)
{
- _logger.LogDebug("Terminating remaining child processes.");
+ Logger.LogDebug("Terminating remaining child processes.");
await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken);
Dispose();
}
@@ -75,7 +77,7 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil
}
public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
{
- _logger.Log(MessageDescriptor.HotReloadSessionStarting);
+ Logger.Log(MessageDescriptor.HotReloadSessionStarting);
var solution = Workspace.CurrentSolution;
@@ -95,7 +97,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
}
}
- _logger.Log(MessageDescriptor.HotReloadSessionStarted);
+ Logger.Log(MessageDescriptor.HotReloadSessionStarted);
}
public async Task TrackRunningProjectAsync(
@@ -140,7 +142,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
};
var launchResult = new ProcessLaunchResult();
- var runningProcess = _processRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token);
+ var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token);
if (launchResult.ProcessId == null)
{
// error already reported
@@ -232,7 +234,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested)
{
// Process exited during initialization. This should not happen since we control the process during this time.
- _logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId);
+ Logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId);
return null;
}
}
@@ -281,7 +283,7 @@ private ImmutableArray GetAggregateCapabilities()
.Order()
.ToImmutableArray();
- _logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities));
+ Logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities));
return capabilities;
}
@@ -341,7 +343,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
{
_hotReloadService.DiscardUpdate();
- _logger.Log(MessageDescriptor.HotReloadSuspended);
+ Logger.Log(MessageDescriptor.HotReloadSuspended);
await Task.Delay(-1, cancellationToken);
return ([], [], [], []);
@@ -409,7 +411,7 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT
return projectsWithPath[0];
}
- return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
+ return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.ProjectInstance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
}
private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, ImmutableDictionary runningProjectInfos, CancellationToken cancellationToken)
@@ -420,11 +422,11 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im
break;
case HotReloadService.Status.NoChangesToApply:
- _logger.Log(MessageDescriptor.NoCSharpChangesToApply);
+ Logger.Log(MessageDescriptor.NoCSharpChangesToApply);
break;
case HotReloadService.Status.Blocked:
- _logger.Log(MessageDescriptor.UnableToApplyChanges);
+ Logger.Log(MessageDescriptor.UnableToApplyChanges);
break;
default:
@@ -433,7 +435,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im
if (!updates.ProjectsToRestart.IsEmpty)
{
- _logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
+ Logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
}
var errorsToDisplayInApp = new List();
@@ -515,7 +517,7 @@ void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, strin
var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);
var args = new[] { autoPrefix, display };
- _logger.Log(descriptor, args);
+ Logger.Log(descriptor, args);
if (autoPrefix != "")
{
@@ -551,19 +553,27 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbos
}
}
- public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList files, ProjectNodeMap projectMap, CancellationToken cancellationToken)
- {
- var allFilesHandled = true;
+ private static readonly string[] s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets];
- var updates = new Dictionary>();
+ private static bool HasScopedCssTargets(ProjectInstance projectInstance)
+ => s_targets.All(t => projectInstance.Targets.ContainsKey(t));
+
+ public async ValueTask HandleStaticAssetChangesAsync(
+ IReadOnlyList files,
+ ProjectNodeMap projectMap,
+ IReadOnlyDictionary manifests,
+ 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 (file.StaticWebAssetPath is null)
+ if (!isScopedCss && file.StaticWebAssetRelativeUrl is null)
{
- allFilesHandled = false;
continue;
}
@@ -572,48 +582,145 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList? failedApplicationProjectInstances = null;
+ if (projectInstancesToRegenerate.Count > 0)
+ {
+ var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions);
+
+ // Note: MSBuild only allows one build at a time in a process.
+ foreach (var projectInstance in projectInstancesToRegenerate)
+ {
+ Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName());
+
+ using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss");
+
+ // Deep copy so that we don't pollute the project graph:
+ if (!projectInstance.DeepCopy().Build(s_targets, loggers))
+ {
+ loggers.ReportOutput();
+
+ failedApplicationProjectInstances ??= [];
+ failedApplicationProjectInstances.Add(projectInstance);
+ }
+ }
}
- var tasks = updates.Select(async entry =>
+ var tasks = assets.Select(async entry =>
{
- var (runningProject, assets) = entry;
- using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken);
- await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, processCommunicationCancellationSource.Token);
+ var (applicationProjectInstance, instanceAssets) = entry;
+
+ if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true)
+ {
+ return;
+ }
+
+ if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects))
+ {
+ return;
+ }
+
+ foreach (var runningProject in runningProjects)
+ {
+ using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken);
+ await runningProject.Clients.ApplyStaticAssetUpdatesAsync(instanceAssets.Values, processCommunicationCancellationSource.Token);
+ }
});
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
- _logger.Log(MessageDescriptor.HotReloadOfStaticAssetsSucceeded);
-
- return allFilesHandled;
+ Logger.Log(MessageDescriptor.StaticAssetsReloaded);
}
///
diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs
index 52df0fa62829..b596468481af 100644
--- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs
+++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs
@@ -3,6 +3,8 @@
using System.Collections.Immutable;
using System.Diagnostics;
+using System.Text.Encodings.Web;
+using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.DotNet.HotReload;
@@ -113,8 +115,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
}
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger);
- compilationHandler = new CompilationHandler(_context.Logger, _context.ProcessRunner);
- var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, _context.BrowserRefreshServerFactory, _context.Options, _context.EnvironmentOptions);
+ compilationHandler = new CompilationHandler(_context);
var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration);
evaluationResult.ItemExclusions.Report(_context.Logger);
@@ -260,13 +261,9 @@ void FileChangedCallback(ChangedPath change)
var stopwatch = Stopwatch.StartNew();
HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler);
- await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, iterationCancellationToken);
+ await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, iterationCancellationToken);
HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler);
- HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.ScopedCssHandler);
- await scopedCssFileHandler.HandleFileChangesAsync(changedFiles, iterationCancellationToken);
- HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.ScopedCssHandler);
-
HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler);
var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
@@ -399,7 +396,7 @@ await Task.WhenAll(
_context.Logger.Log(MessageDescriptor.HotReloadChangeHandled, stopwatch.ElapsedMilliseconds);
- async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects)
+ async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects)
{
var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
if (changedPaths is [])
@@ -432,22 +429,14 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra
new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] },
changedPath.Kind);
})
- .ToImmutableList();
+ .ToList();
ReportFileChanges(changedFiles);
- // When a new file is added we need to run design-time build to find out
- // what kind of the file it is and which project(s) does it belong to (can be linked, web asset, etc.).
- // We also need to re-evaluate the project if any project files have been modified.
- // We don't need to rebuild and restart the application though.
- var fileAdded = changedFiles.Any(f => f.Kind is ChangeKind.Add);
- var projectChanged = !fileAdded && changedFiles.Any(f => evaluationResult.BuildFiles.Contains(f.Item.FilePath));
- var evaluationRequired = fileAdded || projectChanged;
+ AnalyzeFileChanges(changedFiles, evaluationResult, out var evaluationRequired);
if (evaluationRequired)
{
- _context.Logger.Log(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation);
-
// TODO: consider re-evaluating only affected projects instead of the whole graph.
evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken);
@@ -463,9 +452,14 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra
}
// Update files in the change set with new evaluation info.
- changedFiles = [.. changedFiles
- .Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f)
- ];
+ for (var i = 0; i < changedFiles.Count; i++)
+ {
+ var file = changedFiles[i];
+ if (evaluationResult.Files.TryGetValue(file.Item.FilePath, out var evaluatedFile))
+ {
+ changedFiles[i] = file with { Item = evaluatedFile };
+ }
+ }
_context.Logger.Log(MessageDescriptor.ReEvaluationCompleted);
}
@@ -478,13 +472,13 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra
var rebuiltProjectPaths = rebuiltProjects.ToHashSet();
var newAccumulator = ImmutableList.Empty;
- var newChangedFiles = ImmutableList.Empty;
+ var newChangedFiles = new List();
foreach (var file in changedFiles)
{
if (file.Item.ContainingProjectPaths.All(containingProjectPath => rebuiltProjectPaths.Contains(containingProjectPath)))
{
- newChangedFiles = newChangedFiles.Add(file);
+ newChangedFiles.Add(file);
}
else
{
@@ -504,7 +498,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra
await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken);
}
- return changedFiles;
+ return [.. changedFiles];
}
}
}
@@ -560,6 +554,102 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra
}
}
+ private void AnalyzeFileChanges(
+ List changedFiles,
+ EvaluationResult evaluationResult,
+ out bool evaluationRequired)
+ {
+ // If any build file changed (project, props, targets) we need to re-evaluate the projects.
+ // Currently we re-evaluate the whole project graph even if only a single project file changed.
+ if (changedFiles.Select(f => f.Item.FilePath).FirstOrDefault(path => evaluationResult.BuildFiles.Contains(path) || MatchesBuildFile(path)) is { } firstBuildFilePath)
+ {
+ _context.Logger.Log(MessageDescriptor.ProjectChangeTriggeredReEvaluation, firstBuildFilePath);
+ evaluationRequired = true;
+ return;
+ }
+
+ for (var i = 0; i < changedFiles.Count; i++)
+ {
+ var changedFile = changedFiles[i];
+ var filePath = changedFile.Item.FilePath;
+
+ if (changedFile.Kind is ChangeKind.Add)
+ {
+ if (MatchesStaticWebAssetFilePattern(evaluationResult, filePath, out var staticWebAssetUrl))
+ {
+ changedFiles[i] = changedFile with
+ {
+ Item = changedFile.Item with { StaticWebAssetRelativeUrl = staticWebAssetUrl }
+ };
+ }
+ else
+ {
+ // TODO: Get patterns from evaluation that match Compile, AdditionalFile, AnalyzerConfigFile items.
+ // Avoid re-evaluating on addition of files that don't affect the project.
+
+ // project file or other file:
+ _context.Logger.Log(MessageDescriptor.FileAdditionTriggeredReEvaluation, filePath);
+ evaluationRequired = true;
+ return;
+ }
+ }
+ }
+
+ evaluationRequired = false;
+ }
+
+ ///
+ /// True if the file path looks like a file that might be imported by MSBuild.
+ ///
+ private static bool MatchesBuildFile(string filePath)
+ {
+ var extension = Path.GetExtension(filePath);
+ return extension.Equals(".props", PathUtilities.OSSpecificPathComparison)
+ || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison)
+ || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison)
+ || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison);
+ }
+
+ ///
+ /// Determines if the given file path is a static web asset file path based on
+ /// the discovery patterns.
+ ///
+ private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluationResult, string filePath, out string? staticWebAssetUrl)
+ {
+ staticWebAssetUrl = null;
+
+ if (StaticWebAsset.IsScopedCssFile(filePath))
+ {
+ return true;
+ }
+
+ foreach (var (_, manifest) in evaluationResult.StaticWebAssetsManifests)
+ {
+ foreach (var pattern in manifest.DiscoveryPatterns)
+ {
+ var match = pattern.Glob.MatchInfo(filePath);
+ if (match.IsMatch)
+ {
+ var dirUrl = match.WildcardDirectoryPartMatchGroup.Replace(Path.DirectorySeparatorChar, '/');
+
+ Debug.Assert(!dirUrl.EndsWith('/'));
+ Debug.Assert(!pattern.BaseUrl.EndsWith('/'));
+
+ var url = UrlEncoder.Default.Encode(dirUrl + "/" + match.FilenamePartMatchGroup);
+ if (pattern.BaseUrl != "")
+ {
+ url = pattern.BaseUrl + "/" + url;
+ }
+
+ staticWebAssetUrl = url;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray projectPaths, CancellationToken cancellationToken)
{
var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer);
diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs
index 04b3dbb70d52..5fb26269ae33 100644
--- a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs
+++ b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs
@@ -14,7 +14,6 @@ public enum StartType
Main,
StaticHandler,
CompilationHandler,
- ScopedCssHandler
}
internal sealed class Keywords
diff --git a/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs
deleted file mode 100644
index 09e33759e7a4..000000000000
--- a/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-
-using Microsoft.Build.Graph;
-using Microsoft.Extensions.Logging;
-
-namespace Microsoft.DotNet.Watch
-{
- internal sealed class ScopedCssFileHandler(ILogger logger, ILogger buildLogger, ProjectNodeMap projectMap, BrowserRefreshServerFactory browserConnector, GlobalOptions options, EnvironmentOptions environmentOptions)
- {
- private const string BuildTargetName = TargetNames.GenerateComputedBuildStaticWebAssets;
-
- public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken)
- {
- var projectsToRefresh = new HashSet();
- var hasApplicableFiles = false;
-
- for (int i = 0; i < files.Count; i++)
- {
- var file = files[i].Item;
-
- if (!file.FilePath.EndsWith(".razor.css", StringComparison.Ordinal) &&
- !file.FilePath.EndsWith(".cshtml.css", StringComparison.Ordinal))
- {
- continue;
- }
-
- hasApplicableFiles = true;
- logger.LogDebug("Handling file change event for scoped css file {FilePath}.", file.FilePath);
- foreach (var containingProjectPath in file.ContainingProjectPaths)
- {
- if (!projectMap.Map.TryGetValue(containingProjectPath, out var projectNodes))
- {
- // Shouldn't happen.
- logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath);
- continue;
- }
-
- // Build and refresh each instance (TFM) of the project.
- foreach (var projectNode in projectNodes)
- {
- // The outer build project instance (that specifies TargetFrameworks) won't have the target.
- if (projectNode.ProjectInstance.Targets.ContainsKey(BuildTargetName))
- {
- projectsToRefresh.Add(projectNode);
- }
- }
- }
- }
-
- if (!hasApplicableFiles)
- {
- return;
- }
-
- var buildReporter = new BuildReporter(buildLogger, options, environmentOptions);
-
- var buildTasks = projectsToRefresh.Select(projectNode => Task.Run(() =>
- {
- using var loggers = buildReporter.GetLoggers(projectNode.ProjectInstance.FullPath, BuildTargetName);
-
- // Deep copy so that we don't pollute the project graph:
- if (!projectNode.ProjectInstance.DeepCopy().Build(BuildTargetName, loggers))
- {
- loggers.ReportOutput();
- return null;
- }
-
- return projectNode;
- }));
-
- var buildResults = await Task.WhenAll(buildTasks).WaitAsync(cancellationToken);
-
- var browserRefreshTasks = buildResults.Where(p => p != null)!.GetAncestorsAndSelf().Select(async projectNode =>
- {
- if (browserConnector.TryGetRefreshServer(projectNode, out var browserRefreshServer))
- {
- // We'd like an accurate scoped css path, but this needs a lot of work to wire-up now.
- // We'll handle this as part of https://github.com/dotnet/aspnetcore/issues/31217.
- // For now, we'll make it look like some css file which would cause JS to update a
- // single file if it's from the current project, or all locally hosted css files if it's a file from
- // referenced project.
- var relativeUrl = Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath) + ".css";
- await browserRefreshServer.UpdateStaticAssetsAsync([relativeUrl], cancellationToken);
- }
- });
-
- await Task.WhenAll(browserRefreshTasks).WaitAsync(cancellationToken);
-
- var successfulCount = buildResults.Sum(r => r != null ? 1 : 0);
-
- if (successfulCount == buildResults.Length)
- {
- logger.Log(MessageDescriptor.HotReloadOfScopedCssSucceeded);
- }
- else if (successfulCount > 0)
- {
- logger.Log(MessageDescriptor.HotReloadOfScopedCssPartiallySucceeded, successfulCount, buildResults.Length);
- }
- else
- {
- logger.Log(MessageDescriptor.HotReloadOfScopedCssFailed);
- }
- }
- }
-}
diff --git a/src/BuiltInTools/Watch/UI/IReporter.cs b/src/BuiltInTools/Watch/UI/IReporter.cs
index 82b60996e1ab..079ce3e46101 100644
--- a/src/BuiltInTools/Watch/UI/IReporter.cs
+++ b/src/BuiltInTools/Watch/UI/IReporter.cs
@@ -196,12 +196,12 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition)
public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default);
public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, LogLevel.Information);
public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, LogLevel.Information);
- public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation.", Emoji.Watch, LogLevel.Debug);
+ public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Trace);
+ public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Trace);
+ public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Trace);
+ public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug);
+ public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug);
public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation.", Emoji.Watch, LogLevel.Debug);
public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# changes to apply.", Emoji.Watch, LogLevel.Information);
public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information);
public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error);
@@ -215,10 +215,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition)
public static readonly MessageDescriptor TerminatingProcess = Create("Terminating process {0} ({1}).", Emoji.Watch, LogLevel.Debug);
public static readonly MessageDescriptor FailedToSendSignalToProcess = Create("Failed to send {0} signal to process {1}: {2}", Emoji.Warning, LogLevel.Warning);
public static readonly MessageDescriptor ErrorReadingProcessOutput = Create("Error reading {0} of process {1}: {2}", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor HotReloadOfScopedCssSucceeded = Create("Hot reload of scoped css succeeded.", Emoji.HotReload, LogLevel.Information);
- public static readonly MessageDescriptor HotReloadOfScopedCssPartiallySucceeded = Create("Hot reload of scoped css partially succeeded: {0} project(s) out of {1} were updated.", Emoji.HotReload, LogLevel.Information);
- public static readonly MessageDescriptor HotReloadOfScopedCssFailed = Create("Hot reload of scoped css failed.", Emoji.Error, LogLevel.Error);
- public static readonly MessageDescriptor HotReloadOfStaticAssetsSucceeded = Create("Hot reload of static assets succeeded.", Emoji.HotReload, LogLevel.Information);
+ public static readonly MessageDescriptor StaticAssetsReloaded = Create("Static assets reloaded.", Emoji.HotReload, LogLevel.Information);
public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default);
public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, LogLevel.Debug);
public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, LogLevel.Information);
@@ -232,7 +229,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition)
public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, LogLevel.Debug);
public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, LogLevel.Debug);
public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug);
- public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Debug);
+ public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace);
public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information);
public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information);
public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information);
diff --git a/src/BuiltInTools/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch.slnf
index 181ae2f51023..5958ed1c0844 100644
--- a/src/BuiltInTools/dotnet-watch.slnf
+++ b/src/BuiltInTools/dotnet-watch.slnf
@@ -18,21 +18,20 @@
"src\\BuiltInTools\\HotReloadAgent\\Microsoft.DotNet.HotReload.Agent.shproj",
"src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.Package.csproj",
"src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.shproj",
- "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj",
- "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.shproj",
"src\\BuiltInTools\\Watch.Aspire\\Microsoft.DotNet.HotReload.Watch.Aspire.csproj",
"src\\BuiltInTools\\Watch\\Microsoft.DotNet.HotReload.Watch.csproj",
+ "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj",
+ "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.shproj",
"src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj",
"src\\Microsoft.DotNet.ProjectTools\\Microsoft.DotNet.ProjectTools.csproj",
"test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj",
"test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj",
+ "test\\Microsoft.DotNet.HotReload.Test.Utilities\\Microsoft.DotNet.HotReload.Test.Utilities.csproj",
+ "test\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj",
"test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj",
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj",
"test\\Microsoft.WebTools.AspireService.Tests\\Microsoft.WebTools.AspireService.Tests.csproj",
"test\\dotnet-watch-test-browser\\dotnet-watch-test-browser.csproj",
- "test\\Microsoft.DotNet.HotReload.Test.Utilities\\Microsoft.DotNet.HotReload.Test.Utilities.csproj",
- "test\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj",
- "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj",
"test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj"
]
}
diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs
index 384df13ae5cc..476108f319c7 100644
--- a/src/BuiltInTools/dotnet-watch/Program.cs
+++ b/src/BuiltInTools/dotnet-watch/Program.cs
@@ -46,6 +46,9 @@ public static async Task Main(string[] args)
var environmentOptions = EnvironmentOptions.FromEnvironment(processPath);
+ // msbuild tasks depend on host path variable:
+ Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.MuxerPath);
+
var program = TryCreate(
args,
new PhysicalConsole(environmentOptions.TestFlags),
diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json
index 9e28729eb807..811e5676280b 100644
--- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json
+++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json
@@ -2,14 +2,15 @@
"profiles": {
"dotnet-watch": {
"commandName": "Project",
- "commandLineArgs": "--verbose -bl",
- "workingDirectory": "C:\\bugs\\9756\\aspire-watch-start-issue\\Aspire.AppHost",
+ "commandLineArgs": "-bl -f net10.0-windows10.0.19041.0",
+ "workingDirectory": "C:\\Temp\\MauiBlazor4\\App",
"environmentVariables": {
"DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)",
"DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000",
"DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS": "100000",
"DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000",
- "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1"
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1",
+ "DOTNET_CLI_CONTEXT_VERBOSE": "true"
}
}
}
diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs
index 32f8f2a0d58d..22e2a05ce912 100644
--- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs
+++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs
@@ -107,7 +107,7 @@ void AddFile(string filePath, string? staticWebAssetPath)
{
FilePath = filePath,
ContainingProjectPaths = [projectPath],
- StaticWebAssetPath = staticWebAssetPath,
+ StaticWebAssetRelativeUrl = staticWebAssetPath,
});
}
else if (!existingFile.ContainingProjectPaths.Contains(projectPath))
diff --git a/src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs
similarity index 95%
rename from src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs
rename to src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs
index d20603095ab3..538493e1101f 100644
--- a/src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs
+++ b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs
@@ -19,7 +19,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f
{
var file = files[i].Item;
- if (file.StaticWebAssetPath is null)
+ if (file.StaticWebAssetRelativeUrl is null)
{
allFilesHandled = false;
continue;
@@ -46,7 +46,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f
refreshRequests.Add(refreshServer, filesPerServer = []);
}
- filesPerServer.Add(file.StaticWebAssetPath);
+ filesPerServer.Add(file.StaticWebAssetRelativeUrl);
}
else if (projectsWithoutRefreshServer.Add(projectNode))
{
@@ -65,7 +65,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
- logger.Log(MessageDescriptor.HotReloadOfStaticAssetsSucceeded);
+ logger.Log(MessageDescriptor.StaticAssetsReloaded);
return allFilesHandled;
}
diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj
index 19c3be5116f6..1253990897ce 100644
--- a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj
+++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj
@@ -13,6 +13,7 @@
+
diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs
new file mode 100644
index 000000000000..f2478425388d
--- /dev/null
+++ b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs
@@ -0,0 +1,383 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.DotNet.Watch.UnitTests;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.DotNet.HotReload.UnitTests;
+
+public class StaticWebAssetsManifestTests(ITestOutputHelper testOutput)
+{
+ private static MemoryStream CreateStream(string content)
+ => new(Encoding.UTF8.GetBytes(content));
+
+ [Fact]
+ public void TryParse_Empty()
+ {
+ using var stream = CreateStream("null");
+ var logger = new TestLogger(testOutput);
+ Assert.Null(StaticWebAssetsManifest.TryParse(stream, "file.json", logger));
+ Assert.True(logger.HasError);
+ }
+
+ [Fact]
+ public void TryParse_MissingContentRoots()
+ {
+ using var stream = CreateStream("""
+ {
+ "Root": {
+ "Children": {
+ "site.css": {
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "css/site.css"
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ Assert.Null(StaticWebAssetsManifest.TryParse(stream, "file.json", logger));
+ Assert.True(logger.HasError);
+ }
+
+ [Fact]
+ public void TryParse_InvalidRootIndex()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}"
+ ],
+ "Root": {
+ "Children": {
+ "site.css": {
+ "Asset": {
+ "ContentRootIndex": 1,
+ "SubPath": "css/site.css"
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+
+ AssertEx.SequenceEqual(
+ [
+ "[Warning] Failed to parse 'file.json': Invalid value of ContentRootIndex: 1",
+ ], logger.GetAndClearMessages());
+
+ Assert.NotNull(manifest);
+ Assert.Empty(manifest.UrlToPathMap);
+ Assert.Empty(manifest.DiscoveryPatterns);
+ }
+
+ [Fact]
+ public void TryParse_InvalidCharactersInSubPath()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}"
+ ],
+ "Root": {
+ "Children": {
+ "site.css": {
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "<>.css"
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+ Assert.Empty(logger.GetAndClearMessages());
+ Assert.NotNull(manifest);
+
+ AssertEx.SequenceEqual(
+ [
+ new("site.css", Path.Join(root, "<>.css")),
+ ], manifest.UrlToPathMap.OrderBy(e => e.Key));
+ }
+
+ [Fact]
+ public void TryParse_NoChildren()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}Classlib2\\bundles\\"
+ ],
+ "Root": {
+ "Children": {
+ }
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+ Assert.NotNull(manifest);
+ Assert.False(logger.HasWarning);
+ Assert.Empty(manifest.UrlToPathMap);
+ Assert.Empty(manifest.DiscoveryPatterns);
+ }
+
+ [Fact]
+ public void TryParse_TopAsset()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}Classlib2\\bundles\\"
+ ],
+ "Root": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "css/site.css"
+ },
+ "Patterns": null
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+ AssertEx.SequenceEqual(
+ [
+ "[Warning] Failed to parse 'file.json': Asset has no URL",
+ ], logger.GetAndClearMessages());
+
+ Assert.NotNull(manifest);
+ Assert.Empty(manifest.UrlToPathMap);
+ Assert.Empty(manifest.DiscoveryPatterns);
+ }
+
+ [Fact]
+ public void TryParse_RootIsNotFullPath()
+ {
+ using var stream = CreateStream("""
+ {
+ "ContentRoots": [
+ "a/b"
+ ],
+ "Root": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "css/site.css"
+ },
+ "Patterns": null
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+ Assert.True(logger.HasWarning);
+ Assert.NotNull(manifest);
+ Assert.Empty(manifest.UrlToPathMap);
+ Assert.Empty(manifest.DiscoveryPatterns);
+ }
+
+ [Fact]
+ public void TryParse_ValidFile()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}Classlib2\\bundles\\",
+ "{{rootEscaped}}Classlib2\\scopedcss\\",
+ "{{rootEscaped}}Classlib2\\SomePath\\",
+ "{{rootEscaped}}Classlib\\wwwroot\\",
+ "{{rootEscaped}}Classlib2\\wwwroot\\",
+ "{{rootEscaped}}Classlib3\\somePath\\"
+ ],
+ "Root": {
+ "Children": {
+ "css": {
+ "Children": {
+ "site.css": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "css/site.css"
+ },
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ },
+ "_content": {
+ "Children": {
+ "Classlib": {
+ "Children": {
+ "css": {
+ "Children": {
+ "site.css": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 3,
+ "SubPath": "css/site.css"
+ },
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ },
+ "Classlib2": {
+ "Children": {
+ "Classlib2.bundle.fingerprint.scp.css": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 2,
+ "SubPath": "Classlib2.bundle.scp.css"
+ },
+ "Patterns": null
+ },
+ "background.png": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 4,
+ "SubPath": "background.png"
+ },
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ },
+ "Classlib3": {
+ "Children": {
+ "background.png": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 5,
+ "SubPath": "background.png"
+ },
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ }
+ },
+ "Asset": null,
+ "Patterns": null
+ },
+ "Classlib2.styles.css": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "Classlib2.styles.css"
+ },
+ "Patterns": null
+ },
+ "Classlib2.scopedstyles.css": {
+ "Children": null,
+ "Asset": {
+ "ContentRootIndex": 1,
+ "SubPath": "Classlib2.scopedstyles.css"
+ },
+ "Patterns": null
+ }
+ }
+ }
+ }
+ """);
+
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", NullLogger.Instance);
+
+ Assert.NotNull(manifest);
+ AssertEx.SequenceEqual(
+ [
+ new("_content/Classlib/css/site.css", Path.Combine(root, "Classlib", "wwwroot", "css", "site.css")),
+ new("_content/Classlib2/background.png", Path.Combine(root, "Classlib2", "wwwroot", "background.png")),
+ new("_content/Classlib2/Classlib2.bundle.fingerprint.scp.css", Path.Combine(root, "Classlib2", "SomePath", "Classlib2.bundle.scp.css")),
+ new("_content/Classlib3/background.png", Path.Combine(root, "Classlib3", "somePath", "background.png")),
+ new("Classlib2.scopedstyles.css", Path.Combine(root, "Classlib2", "scopedcss", "Classlib2.scopedstyles.css")),
+ new("Classlib2.styles.css", Path.Combine(root, "Classlib2", "bundles", "Classlib2.styles.css")),
+ new("css/site.css", Path.Combine(root, "Classlib2", "bundles", "css", "site.css")),
+ ], manifest.UrlToPathMap.OrderBy(e => e.Key));
+
+ Assert.Empty(manifest.DiscoveryPatterns);
+ }
+
+ [Fact]
+ public void TryParse_Patterns()
+ {
+ var root = Path.GetTempPath();
+ var rootEscaped = root.Replace("\\", "\\\\");
+
+ using var stream = CreateStream($$"""
+ {
+ "ContentRoots": [
+ "{{rootEscaped}}"
+ ],
+ "Root": {
+ "Children": {
+ "site.css" : {
+ "Asset": {
+ "ContentRootIndex": 0,
+ "SubPath": "css/site.css"
+ }
+ }
+ },
+ "Patterns": [
+ {
+ "ContentRootIndex": 0,
+ "Pattern": "**",
+ "Depth": 0
+ }
+ ]
+ }
+ }
+ """);
+
+ var logger = new TestLogger(testOutput);
+ var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger);
+ Assert.False(logger.HasWarning);
+ Assert.NotNull(manifest);
+
+ AssertEx.SequenceEqual(
+ [
+ new("site.css", Path.Combine(root, "css", "site.css")),
+ ], manifest.UrlToPathMap.OrderBy(e => e.Key));
+
+ AssertEx.SequenceEqual(
+ [
+ $"{root};**;"
+ ], manifest.DiscoveryPatterns.Select(p => $"{p.Directory};{p.Pattern};{p.BaseUrl}"));
+ }
+}
diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
index 562d9e67bd0a..377919334a22 100644
--- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
+++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs
@@ -8,17 +8,23 @@ namespace Microsoft.DotNet.Watch.UnitTests;
internal class TestLogger(ITestOutputHelper? output = null) : ILogger
{
- public readonly Lock Guard = new();
+ public readonly object Guard = new();
private readonly List _messages = [];
public Func IsEnabledImpl = _ => true;
+ public bool HasError { get; private set; }
+ public bool HasWarning { get; private set; }
+
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
var message = $"[{logLevel}] {formatter(state, exception)}";
lock (Guard)
{
+ HasError |= logLevel is LogLevel.Error or LogLevel.Critical;
+ HasWarning |= logLevel is LogLevel.Warning;
+
_messages.Add(message);
output?.WriteLine(message);
}
diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj
index ba7abcd3a1cb..bd9a74bd0769 100644
--- a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj
+++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj
@@ -5,7 +5,6 @@
enable
MicrosoftAspNetCore
true
- true
true