diff --git a/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs b/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs index d154e76c4097..0ac8b45e870a 100644 --- a/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs +++ b/src/Dotnet.Watch/HotReloadClient/Logging/LogEvents.cs @@ -76,5 +76,6 @@ public static void Log(this ILogger logger, LogEvent<(TArg1 public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'."); public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}."); public static readonly LogEvent ConnectedToRefreshServer = Create(LogLevel.Debug, "Connected to refresh server."); + public static readonly LogEvent ManifestFileNotFound = Create(LogLevel.Debug, "Manifest file '{0}' not found."); } diff --git a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs index 6f9ad71f4bab..64cfcc41bfa5 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -92,7 +92,7 @@ public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out } catch (FileNotFoundException) { - logger.LogDebug("File '{FilePath}' does not exist.", path); + logger.Log(LogEvents.ManifestFileNotFound, path); return null; } catch (Exception e) diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs index 0ccc26894288..8a23ac90f376 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs @@ -31,7 +31,6 @@ protected async Task LaunchWatcherAsync( EnvironmentOptions = EnvironmentOptions, MainProjectOptions = null, BuildArguments = [], - TargetFramework = null, RootProjects = rootProjects, BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), BrowserLauncher = new BrowserLauncher(logger, Reporter, EnvironmentOptions), diff --git a/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs b/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs index d97d9bb5b57c..c429d5681b4b 100644 --- a/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs +++ b/src/Dotnet.Watch/Watch/Build/EvaluationResult.cs @@ -17,39 +17,26 @@ internal sealed class EvaluationResult( IReadOnlyDictionary staticWebAssetsManifests, ProjectBuildManager buildManager) { - public readonly IReadOnlyDictionary Files = files; - public readonly LoadedProjectGraph ProjectGraph = projectGraph; - public readonly ProjectBuildManager BuildManager = buildManager; + public IReadOnlyDictionary Files => files; + public LoadedProjectGraph ProjectGraph => projectGraph; + public ProjectBuildManager BuildManager => buildManager; public readonly FilePathExclusions ItemExclusions = projectGraph != null ? FilePathExclusions.Create(projectGraph.Graph) : FilePathExclusions.Empty; - private readonly Lazy> _lazyBuildFiles - = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph.Graph) : new HashSet()); - - private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) - => projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) - .Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath)) - .ToHashSet(PathUtilities.OSSpecificPathComparer); - - public IReadOnlySet BuildFiles - => _lazyBuildFiles.Value; - public IReadOnlyDictionary StaticWebAssetsManifests => staticWebAssetsManifests; public IReadOnlyDictionary RestoredProjectInstances => restoredProjectInstances; - public void WatchFiles(FileWatcher fileWatcher) + public void WatchFileItems(FileWatcher fileWatcher) { fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); fileWatcher.WatchContainingDirectories( StaticWebAssetsManifests.Values.SelectMany(static manifest => manifest.DiscoveryPatterns.Select(static pattern => pattern.Directory)), includeSubdirectories: true); - - fileWatcher.WatchFiles(BuildFiles); } public static ImmutableDictionary GetGlobalBuildProperties(IEnumerable buildArguments, EnvironmentOptions environmentOptions) @@ -70,31 +57,24 @@ public static ImmutableDictionary GetGlobalBuildProperties(IEnum /// Loads project graph and performs design-time build. /// public static async ValueTask TryCreateAsync( - ProjectGraphFactory factory, + LoadedProjectGraph projectGraph, + ILogger logger, GlobalOptions globalOptions, EnvironmentOptions environmentOptions, + string? mainProjectTargetFramework, bool restore, CancellationToken cancellationToken) { - var logger = factory.Logger; - var stopwatch = Stopwatch.StartNew(); - - var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: true, cancellationToken); + logger.Log(MessageDescriptor.LoadingProjects); - if (projectGraph == null) - { - return null; - } + var projectLoadingStopwatch = Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); var buildReporter = new BuildReporter(projectGraph.Logger, globalOptions, environmentOptions); var buildManager = new ProjectBuildManager(projectGraph.ProjectCollection, buildReporter); - logger.LogDebug("Project graph loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); - if (restore) { - stopwatch.Restart(); - var restoreRequests = projectGraph.Graph.GraphRoots.Select(node => BuildRequest.Create(node.ProjectInstance, [TargetNames.Restore])).ToArray(); if (await buildManager.BuildAsync( @@ -126,12 +106,7 @@ public static ImmutableDictionary GetGlobalBuildProperties(IEnum // Update the project instances of the graph with design-time build results. // The properties and items set by DTB will be used by the Workspace to create Roslyn representation of projects. - var buildRequests = - (from node in projectGraph.Graph.ProjectNodesTopologicallySorted - where node.ProjectInstance.GetPropertyValue(PropertyNames.TargetFramework) != "" - let targets = GetBuildTargets(node.ProjectInstance, environmentOptions) - where targets is not [] - select BuildRequest.Create(node.ProjectInstance, [.. targets])).ToArray(); + var buildRequests = CreateDesignTimeBuildRequests(projectGraph.Graph, mainProjectTargetFramework, environmentOptions.SuppressHandlingStaticWebAssets).ToImmutableArray(); var buildResults = await buildManager.BuildAsync( buildRequests, @@ -151,6 +126,7 @@ where targets is not [] } logger.LogDebug("Design-time build completed in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + logger.Log(MessageDescriptor.LoadedProjects, projectGraph.Graph.ProjectNodes.Count, projectLoadingStopwatch.Elapsed.TotalSeconds); ProcessBuildResults(buildResults, logger, out var fileItems, out var staticWebAssetManifests); @@ -159,6 +135,28 @@ where targets is not [] return new EvaluationResult(projectGraph, restoredProjectInstances, fileItems, staticWebAssetManifests, buildManager); } + // internal for testing + internal static IEnumerable> CreateDesignTimeBuildRequests(ProjectGraph graph, string? mainProjectTargetFramework, bool suppressStaticWebAssets) + { + return from node in graph.ProjectNodesTopologicallySorted + let targetFramework = node.ProjectInstance.GetTargetFramework() + + // skip outer-build projects + where targetFramework != "" + + // skip root projects that do not match main project TFM, if specified: + where mainProjectTargetFramework == null || + targetFramework == mainProjectTargetFramework || + HasParentWithTargetFramework(node) + + let targets = GetBuildTargets(node.ProjectInstance, suppressStaticWebAssets) + where targets is not [] + select BuildRequest.Create(node.ProjectInstance, [.. targets]); + + static bool HasParentWithTargetFramework(ProjectGraphNode node) + => node.ReferencingProjects.Any(p => p.ProjectInstance.GetTargetFramework() != ""); + } + private static void ProcessBuildResults( ImmutableArray> buildResults, ILogger logger, @@ -245,7 +243,7 @@ void AddFile(string relativePath, string? staticWebAssetRelativeUrl) staticWebAssetManifests = staticWebAssetManifestsBuilder; } - private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) + private static string[] GetBuildTargets(ProjectInstance projectInstance, bool suppressStaticWebAssets) { var compileTarget = projectInstance.Targets.ContainsKey(TargetNames.CompileDesignTime) ? TargetNames.CompileDesignTime @@ -263,7 +261,7 @@ private static string[] GetBuildTargets(ProjectInstance projectInstance, Environ compileTarget }; - if (!environmentOptions.SuppressHandlingStaticWebAssets) + if (!suppressStaticWebAssets) { // generates static file asset manifest if (projectInstance.Targets.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets)) diff --git a/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs b/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs index 93ed69be35b2..984680c70ec1 100644 --- a/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs +++ b/src/Dotnet.Watch/Watch/Build/LoadedProjectGraph.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Microsoft.Build.Evaluation; using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; @@ -9,21 +10,28 @@ namespace Microsoft.DotNet.Watch { internal sealed class LoadedProjectGraph(ProjectGraph graph, ProjectCollection collection, ILogger logger) { - // full path of proj file to list of nodes representing all target frameworks of the project: - public readonly IReadOnlyDictionary> Map = - graph.ProjectNodes.GroupBy(n => n.ProjectInstance.FullPath).ToDictionary( + // full path of proj file to list of nodes representing all target frameworks of the project (excluding outer build nodes): + private readonly IReadOnlyDictionary> _innerBuildNodes = + graph.ProjectNodes.Where(n => n.ProjectInstance.GetTargetFramework() != "").GroupBy(n => n.ProjectInstance.FullPath).ToDictionary( keySelector: static g => g.Key, elementSelector: static g => (IReadOnlyList)[.. g]); + private readonly Lazy> _lazyBuildFiles = new(() => + graph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) + .Concat(graph.ProjectNodes.Select(p => p.ProjectInstance.FullPath)) + .ToHashSet(PathUtilities.OSSpecificPathComparer)); + public ProjectGraph Graph => graph; public ILogger Logger => logger; public ProjectCollection ProjectCollection => collection; + public IReadOnlySet BuildFiles => _lazyBuildFiles.Value; + public IReadOnlyList GetProjectNodes(string projectPath) { - if (Map.TryGetValue(projectPath, out var rootProjectNodes)) + if (_innerBuildNodes.TryGetValue(projectPath, out var nodes)) { - return rootProjectNodes; + return nodes; } logger.LogError("Project '{ProjectPath}' not found in the project graph.", projectPath); @@ -52,7 +60,7 @@ public IReadOnlyList GetProjectNodes(string projectPath) ProjectGraphNode? candidate = null; foreach (var node in projectNodes) { - if (node.ProjectInstance.GetPropertyValue("TargetFramework") == targetFramework) + if (node.ProjectInstance.GetTargetFramework() == targetFramework) { if (candidate != null) { diff --git a/src/Dotnet.Watch/Watch/Build/ProjectGraphFactory.cs b/src/Dotnet.Watch/Watch/Build/ProjectGraphFactory.cs index b73d0fa9d833..ec6583add8f8 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectGraphFactory.cs +++ b/src/Dotnet.Watch/Watch/Build/ProjectGraphFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using System.Reflection; using System.Runtime.Versioning; using Microsoft.Build.Evaluation; @@ -15,7 +16,7 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectGraphFactory( ImmutableArray rootProjects, - string? targetFramework, + string? virtualProjectTargetFramework, ImmutableDictionary buildProperties, ILogger logger) { @@ -36,7 +37,7 @@ internal sealed class ProjectGraphFactory( useAsynchronousLogging: false, reuseProjectRootElementCache: true); - private readonly string _targetFramework = targetFramework ?? GetProductTargetFramework(); + private readonly string _virtualProjectTargetFramework = virtualProjectTargetFramework ?? GetProductTargetFramework(); public ILogger Logger => logger; @@ -55,10 +56,14 @@ private static string GetProductTargetFramework() var entryPoints = rootProjects.Select(p => new ProjectGraphEntryPoint(p.ProjectGraphPath, buildProperties)); try { - return new LoadedProjectGraph( + var stopwatch = Stopwatch.StartNew(); + var graph = new LoadedProjectGraph( new ProjectGraph(entryPoints, _collection, (path, globalProperties, collection) => CreateProjectInstance(path, globalProperties, collection, logger), cancellationToken), _collection, logger); + + logger.LogDebug("Project graph loaded in {Time}s.", stopwatch.Elapsed.TotalSeconds.ToString("0.0")); + return graph; } catch (ProjectCreationFailedException) { @@ -120,7 +125,7 @@ private ProjectInstance CreateProjectInstance(string projectPath, Dictionary { diff --git a/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs b/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs index 4adce83dc6bd..dfbbe6a23fe1 100644 --- a/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs +++ b/src/Dotnet.Watch/Watch/Build/ProjectGraphUtilities.cs @@ -4,13 +4,17 @@ using System.Runtime.Versioning; using Microsoft.Build.Execution; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; namespace Microsoft.DotNet.Watch; internal static class ProjectGraphUtilities { - public static string GetDisplayName(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetDisplayName(); + extension(ProjectGraphNode projectNode) + { + public string GetDisplayName() + => projectNode.ProjectInstance.GetDisplayName(); + } public static string GetDisplayName(this ProjectInstance project) => $"{Path.GetFileNameWithoutExtension(project.FullPath)} ({project.GetTargetFramework()})"; @@ -18,7 +22,7 @@ public static string GetDisplayName(this ProjectInstance project) public static string GetTargetFramework(this ProjectInstance project) => project.GetPropertyValue(PropertyNames.TargetFramework); - public static IEnumerable GetTargetFrameworks(this ProjectInstance project) + public static IReadOnlyList GetTargetFrameworks(this ProjectInstance project) => project.GetStringListPropertyValue(PropertyNames.TargetFrameworks); public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) @@ -69,13 +73,13 @@ public static bool IsAutoRestartEnabled(this ProjectGraphNode projectNode) public static bool AreDefaultItemsEnabled(this ProjectGraphNode projectNode) => projectNode.GetBooleanPropertyValue(PropertyNames.EnableDefaultItems); - public static IEnumerable GetDefaultItemExcludes(this ProjectGraphNode projectNode) + public static IReadOnlyList GetDefaultItemExcludes(this ProjectGraphNode projectNode) => projectNode.GetStringListPropertyValue(PropertyNames.DefaultItemExcludes); - public static IEnumerable GetStringListPropertyValue(this ProjectGraphNode projectNode, string propertyName) + public static IReadOnlyList GetStringListPropertyValue(this ProjectGraphNode projectNode, string propertyName) => projectNode.ProjectInstance.GetStringListPropertyValue(propertyName); - public static IEnumerable GetStringListPropertyValue(this ProjectInstance project, string propertyName) + public static IReadOnlyList GetStringListPropertyValue(this ProjectInstance project, string propertyName) => project.GetPropertyValue(propertyName).Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); public static bool GetBooleanPropertyValue(this ProjectGraphNode projectNode, string propertyName, bool defaultValue = false) @@ -87,15 +91,27 @@ public static bool GetBooleanPropertyValue(this ProjectInstance project, string public static bool GetBooleanMetadataValue(this ProjectItemInstance item, string metadataName, bool defaultValue = false) => item.GetMetadataValue(metadataName) is { Length: > 0 } value ? bool.TryParse(value, out var result) && result : defaultValue; + /// + /// Yields the project itself and all its ancestors, excluding outer build nodes. + /// public static IEnumerable GetAncestorsAndSelf(this ProjectGraphNode project) => GetAncestorsAndSelf([project]); + /// + /// Yields the given projects and all their ancestors, excluding outer build nodes. + /// public static IEnumerable GetAncestorsAndSelf(this IEnumerable projects) => GetTransitiveProjects(projects, static project => project.ReferencingProjects); + /// + /// Yields the project itself and all transitively referenced projects, excluding outer build nodes. + /// public static IEnumerable GetDescendantsAndSelf(this ProjectGraphNode project) => GetDescendantsAndSelf([project]); + /// + /// Yields the given projects and all transitively referenced projects, excluding outer build nodes. + /// public static IEnumerable GetDescendantsAndSelf(this IEnumerable projects) => GetTransitiveProjects(projects, static project => project.ProjectReferences); @@ -113,7 +129,10 @@ private static IEnumerable GetTransitiveProjects(IEnumerable

public required ProjectOptions? MainProjectOptions { get; init; } - ///

- /// Default target framework. - /// - public required string? TargetFramework { get; init; } - /// /// Additional arguments passed to `dotnet build` when building projects. /// diff --git a/src/Dotnet.Watch/Watch/Context/ProjectOptions.cs b/src/Dotnet.Watch/Watch/Context/ProjectOptions.cs index ee8bde50abc5..24c868c05798 100644 --- a/src/Dotnet.Watch/Watch/Context/ProjectOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/ProjectOptions.cs @@ -14,6 +14,14 @@ internal sealed record ProjectOptions public required string WorkingDirectory { get; init; } + /// + /// Target framework to use to launch the project. + /// If the project multi-targets and is null + /// the user will be prompted for the framework in interactive mode + /// or an error is reported in non-interactive mode. + /// + public string? TargetFramework { get; init; } + /// /// No value indicates that no launch profile should be used. /// Null value indicates that the default launch profile should be used. diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs index 85e120eafafc..cae71e2e5e51 100644 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs @@ -96,11 +96,9 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil } } - public async ValueTask StartSessionAsync(CancellationToken cancellationToken) + public async ValueTask StartSessionAsync(ProjectGraph graph, CancellationToken cancellationToken) { - Logger.Log(MessageDescriptor.HotReloadSessionStartingNotification); - - var solution = Workspace.CurrentSolution; + var solution = await UpdateProjectGraphAsync(graph, cancellationToken); await _hotReloadService.StartSessionAsync(solution, cancellationToken); @@ -353,7 +351,7 @@ public async ValueTask GetManagedCodeUpdatesAsync( var runningProjectInfos = (from project in currentSolution.Projects - let runningProject = GetCorrespondingRunningProject(project, runningProjects) + let runningProject = GetCorrespondingRunningProjects(runningProjects, project).FirstOrDefault() where runningProject != null let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled() select (project.Id, info: new HotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject })) @@ -361,7 +359,7 @@ public async ValueTask GetManagedCodeUpdatesAsync( var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken); - await DisplayResultsAsync(updates, runningProjectInfos, cancellationToken); + await DisplayResultsAsync(updates, currentSolution, runningProjectInfos, cancellationToken); if (updates.Status is HotReloadService.Status.NoChangesToApply or HotReloadService.Status.Blocked) { @@ -530,24 +528,7 @@ async Task CompleteApplyOperationAsync() } } - private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary> runningProjects) - { - if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath)) - { - return null; - } - - // msbuild workspace doesn't set TFM if the project is not multi-targeted - var tfm = HotReloadService.GetTargetFramework(project); - if (tfm == null) - { - return projectsWithPath[0]; - } - - return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.ProjectInstance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); - } - - private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, ImmutableDictionary runningProjectInfos, CancellationToken cancellationToken) + private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Solution solution, ImmutableDictionary runningProjectInfos, CancellationToken cancellationToken) { switch (updates.Status) { @@ -607,7 +588,8 @@ void ReportCompilationDiagnostics(DiagnosticSeverity severity) continue; } - ReportDiagnostic(diagnostic, autoPrefix: ""); + // TODO: we don't currently have a project associated with the diagnostic + ReportDiagnostic(diagnostic, projectDisplayPrefix: "", autoPrefix: ""); } } @@ -628,6 +610,10 @@ void ReportRudeEdits() foreach (var (projectId, diagnostics) in updates.TransientDiagnostics) { + // The diagnostic may be reported for a project that has been deleted. + var project = solution.GetProject(projectId); + var projectDisplay = project != null ? $"[{GetProjectInstance(project).GetDisplayName()}] " : ""; + foreach (var diagnostic in diagnostics) { var prefix = @@ -635,7 +621,7 @@ void ReportRudeEdits() projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " : ""; - ReportDiagnostic(diagnostic, prefix); + ReportDiagnostic(diagnostic, projectDisplay, prefix); } } } @@ -643,23 +629,23 @@ void ReportRudeEdits() bool IsAutoRestartEnabled(ProjectId id) => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect; - void ReportDiagnostic(Diagnostic diagnostic, string autoPrefix) + void ReportDiagnostic(Diagnostic diagnostic, string projectDisplayPrefix, string autoPrefix) { - var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); + var message = projectDisplayPrefix + autoPrefix + CSharpDiagnosticFormatter.Instance.Format(diagnostic); if (autoPrefix != "") { - Logger.Log(MessageDescriptor.ApplyUpdate_AutoVerbose, autoPrefix, display); + Logger.Log(MessageDescriptor.ApplyUpdate_AutoVerbose, message); errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage()); } else { var descriptor = GetMessageDescriptor(diagnostic); - Logger.Log(descriptor, display); + Logger.Log(descriptor, message); if (descriptor.Level != LogLevel.None) { - errorsToDisplayInApp.Add(descriptor.GetMessage(display)); + errorsToDisplayInApp.Add(descriptor.GetMessage(message)); } } } @@ -695,6 +681,9 @@ public async ValueTask GetStaticAssetUpdatesAsync( Stopwatch stopwatch, CancellationToken cancellationToken) { + // capture snapshot: + var runningProjects = _runningProjects; + var assets = new Dictionary>(); var projectInstancesToRegenerate = new HashSet(); @@ -710,18 +699,10 @@ public async ValueTask GetStaticAssetUpdatesAsync( foreach (var containingProjectPath in file.ContainingProjectPaths) { - if (!evaluationResult.ProjectGraph.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) - { - // Shouldn't happen. - Logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); - continue; - } - - foreach (var containingProjectNode in containingProjectNodes) + foreach (var containingProjectNode in evaluationResult.ProjectGraph.GetProjectNodes(containingProjectPath)) { if (isScopedCss) { - // The outer build project instance(that specifies TargetFrameworks) won't have the target. if (!HasScopedCssTargets(containingProjectNode.ProjectInstance)) { continue; @@ -733,7 +714,8 @@ public async ValueTask GetStaticAssetUpdatesAsync( foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf()) { var applicationProjectInstance = referencingProjectNode.ProjectInstance; - if (!TryGetRunningProject(applicationProjectInstance.FullPath, out _)) + var runningApplicationProject = GetCorrespondingRunningProjects(runningProjects, applicationProjectInstance).FirstOrDefault(); + if (runningApplicationProject == null) { continue; } @@ -758,14 +740,14 @@ public async ValueTask GetStaticAssetUpdatesAsync( if (!evaluationResult.StaticWebAssetsManifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest)) { // Shouldn't happen. - Logger.LogWarning("[{Project}] Static web asset manifest not found.", containingProjectNode.GetDisplayName()); + runningApplicationProject.ClientLogger.Log(MessageDescriptor.StaticWebAssetManifestNotFound); continue; } if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath)) { // Shouldn't happen. - Logger.LogWarning("[{Project}] Scoped CSS bundle file '{BundleFile}' not found.", containingProjectNode.GetDisplayName(), bundleFileName); + runningApplicationProject.ClientLogger.Log(MessageDescriptor.ScopedCssBundleFileNotFound, bundleFileName); continue; } @@ -838,12 +820,7 @@ public async ValueTask GetStaticAssetUpdatesAsync( continue; } - if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects)) - { - continue; - } - - foreach (var runningProject in runningProjects) + foreach (var runningProject in GetCorrespondingRunningProjects(runningProjects, applicationProjectInstance)) { if (!builder.StaticAssetsToUpdate.TryGetValue(runningProject, out var updatesPerRunningProject)) { @@ -954,12 +931,7 @@ private IReadOnlyList GetRelaunchOperations_NoLock(IReadOnlyLi { foreach (var containingProjectPath in changedFile.Item.ContainingProjectPaths) { - if (!projectGraph.Map.TryGetValue(containingProjectPath, out var containingProjectNodes)) - { - // Shouldn't happen. - Logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); - continue; - } + var containingProjectNodes = projectGraph.GetProjectNodes(containingProjectPath); // Relaunch all projects whose dependency is affected by this file change. foreach (var ancestor in containingProjectNodes[0].GetAncestorsAndSelf()) @@ -982,12 +954,52 @@ private IReadOnlyList GetRelaunchOperations_NoLock(IReadOnlyLi return relaunchOperations; } - public bool TryGetRunningProject(string projectPath, out ImmutableArray projects) + private static IEnumerable GetCorrespondingRunningProjects(ImmutableDictionary> runningProjects, Project project) { - lock (_runningProjectsAndUpdatesGuard) + if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath)) + { + return []; + } + + // msbuild workspace doesn't set TFM if the project is not multi-targeted + var tfm = HotReloadService.GetTargetFramework(project); + if (tfm == null) { - return _runningProjects.TryGetValue(projectPath, out projects); + Debug.Assert(projectsWithPath.All(p => string.Equals(p.GetTargetFramework(), projectsWithPath[0].GetTargetFramework(), StringComparison.OrdinalIgnoreCase))); + return projectsWithPath; + } + + return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); + } + + private static IEnumerable GetCorrespondingRunningProjects(ImmutableDictionary> runningProjects, 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); + + if (!_projectInstances.TryGetValue(project.FilePath, out var instances)) + { + throw new InvalidOperationException($"Project '{project.FilePath}' (id = '{project.Id}') not found in project graph"); + } + + // msbuild workspace doesn't set TFM if the project is not multi-targeted + var tfm = HotReloadService.GetTargetFramework(project); + if (tfm == null) + { + return instances.Single(); + } + + return instances.Single(instance => string.Equals(instance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); } private static ImmutableArray ToManagedCodeUpdates(IEnumerable updates) @@ -1000,12 +1012,13 @@ private static ImmutableDictionary> Crea keySelector: static group => group.Key, elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray()); - public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken) + public async Task UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken) { _projectInstances = CreateProjectInstanceMap(projectGraph); var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken); await SolutionUpdatedAsync(solution, "project update", cancellationToken); + return solution; } public async Task UpdateFileContentAsync(IReadOnlyList changedFiles, CancellationToken cancellationToken) diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index 92ce3027396f..eb83f1f159e1 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -5,6 +5,8 @@ using System.Diagnostics; using System.Text.Encodings.Web; using System.Xml.Linq; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -19,6 +21,7 @@ internal sealed class HotReloadDotNetWatcher private readonly IConsole _console; private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory; private readonly RestartPrompt? _rudeEditRestartPrompt; + private readonly TargetFrameworkSelectionPrompt? _targetFrameworkSelectionPrompt; private readonly DotNetWatchContext _context; private readonly ProjectGraphFactory _designTimeBuildGraphFactory; @@ -41,11 +44,12 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun } _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null); + _targetFrameworkSelectionPrompt = new TargetFrameworkSelectionPrompt(consoleInput); } _designTimeBuildGraphFactory = new ProjectGraphFactory( context.RootProjects, - context.TargetFramework, + context.MainProjectOptions?.TargetFramework, buildProperties: EvaluationResult.GetGlobalBuildProperties( context.BuildArguments, context.EnvironmentOptions), @@ -85,37 +89,76 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) IRuntimeProcessLauncher? runtimeProcessLauncher = null; CompilationHandler? compilationHandler = null; Action? fileChangedCallback = null; + LoadedProjectGraph? projectGraph = null; + BuildProjectsResult? rootProjectsBuildResult = null; try { - var buildSucceeded = await BuildProjectsAsync(_context.RootProjects, iterationCancellationToken); - if (!buildSucceeded) + rootProjectsBuildResult = await BuildProjectsAsync( + _context.RootProjects, + fileWatcher, + _context.MainProjectOptions, + frameworkSelector: _targetFrameworkSelectionPrompt != null ? _targetFrameworkSelectionPrompt.SelectAsync : null, + iterationCancellationToken); + + // Try load project graph and perform design-time build even if the build failed. + // This allows us to watch the project and source files for changes that will trigger restart. + + projectGraph = rootProjectsBuildResult.ProjectGraph ?? TryLoadProjectGraph(iterationCancellationToken); + if (projectGraph == null) { continue; } - // Evaluate the target to find out the set of files to watch. - // In case the app fails to start due to build or other error we can wait for these files to change. + fileWatcher.WatchFiles(projectGraph.BuildFiles); + // Avoid restore since the build above already restored all root projects. - evaluationResult = await EvaluateProjectGraphAsync(restore: false, iterationCancellationToken); + evaluationResult = await TryEvaluateProjectGraphAsync(projectGraph, rootProjectsBuildResult.MainProjectTargetFramework, restore: false, iterationCancellationToken); + if (evaluationResult == null) + { + continue; + } - compilationHandler = new CompilationHandler(_context); - var projectLauncher = new ProjectLauncher(_context, evaluationResult.ProjectGraph, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Logger); + evaluationResult.WatchFileItems(fileWatcher); - var mainProjectOptions = _context.MainProjectOptions; - var mainProject = (mainProjectOptions != null) ? evaluationResult.ProjectGraph.Graph.GraphRoots.Single() : null; + if (!rootProjectsBuildResult.Success) + { + continue; + } + + compilationHandler = new CompilationHandler(_context); + + // The session must start after the project is built and design time build completes, + // so that the EnC service can read document checksums from the PDB and the solution + // can be initialized from the current state of the project instances in the graph. + // + // Starting the session hydrates the contents of solution documents from disk. + // Session must be started before we start accepting file changes to avoid race condition + // when the EnC session hydrates solution documents with their file content after the changes have already been observed. + await compilationHandler.StartSessionAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); + + var projectLauncher = new ProjectLauncher(_context, projectGraph, compilationHandler, iteration); var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; - if (mainProject?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true) + var mainProjectOptions = _context.MainProjectOptions; + if (mainProjectOptions != null) { - Debug.Assert(mainProjectOptions != null); - runtimeProcessLauncherFactory ??= new AspireServiceFactory(mainProjectOptions); - _context.Logger.LogDebug("Using Aspire process launcher."); + mainProjectOptions = mainProjectOptions with { TargetFramework = rootProjectsBuildResult.MainProjectTargetFramework }; + + if (projectGraph.Graph.GraphRoots.Single()?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true) + { + runtimeProcessLauncherFactory ??= new AspireServiceFactory(mainProjectOptions); + _context.Logger.LogDebug("Using Aspire process launcher."); + } } - runtimeProcessLauncher = runtimeProcessLauncherFactory?.Create(projectLauncher); + if (runtimeProcessLauncherFactory != null) + { + runtimeProcessLauncher = runtimeProcessLauncherFactory.Create(projectLauncher); + _context.Logger.Log(MessageDescriptor.RuntimeProcessLauncherCreatedNotification); + } if (mainProjectOptions != null) { @@ -146,29 +189,14 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) // Cancel iteration as soon as the main process exits, so that we don't spent time loading solution, etc. when the process is already dead. mainRunningProject.Process.ExitedCancellationToken.Register(iterationCancellationSource.Cancel); - - if (shutdownCancellationToken.IsCancellationRequested) - { - // Ctrl+C: - return; - } } - await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); - - // Solution must be initialized after we load the solution but before we start watching for file changes to avoid race condition - // when the EnC session captures content of the file after the changes has already been made. - // The session must also start after the project is built, so that the EnC service can read document checksums from the PDB. - await compilationHandler.StartSessionAsync(iterationCancellationToken); - if (shutdownCancellationToken.IsCancellationRequested) { // Ctrl+C: return; } - evaluationResult.WatchFiles(fileWatcher); - var changedFilesAccumulator = ImmutableList.Empty; void FileChangedCallback(ChangedPath change) @@ -242,23 +270,18 @@ await compilationHandler.GetManagedCodeUpdatesAsync( { iterationCancellationToken.ThrowIfCancellationRequested(); - // pause accumulating file changes during build: - fileWatcher.SuppressEvents = true; - try - { - var success = await BuildProjectsAsync([.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], iterationCancellationToken); - if (success) - { - break; - } - } - finally + var result = await BuildProjectsAsync( + [.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], + fileWatcher, + mainProjectOptions, + frameworkSelector: null, + iterationCancellationToken); + + if (result.Success) { - fileWatcher.SuppressEvents = false; + break; } - iterationCancellationToken.ThrowIfCancellationRequested(); - _ = await fileWatcher.WaitForFileChangeAsync( change => AcceptChange(change, evaluationResult), startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), @@ -328,11 +351,32 @@ async Task> CaptureChangedFilesSnapshot(IReadOnlyLis if (evaluationRequired) { - // TODO: consider re-evaluating only affected projects instead of the whole graph. - evaluationResult = await EvaluateProjectGraphAsync(restore: true, iterationCancellationToken); + // TODO: consider reloading/reevaluating only affected projects instead of the whole graph. + + while (true) + { + iterationCancellationToken.ThrowIfCancellationRequested(); + + projectGraph = TryLoadProjectGraph(iterationCancellationToken); + if (projectGraph != null) + { + fileWatcher.WatchFiles(projectGraph.BuildFiles); + + evaluationResult = await TryEvaluateProjectGraphAsync(projectGraph, mainProjectOptions?.TargetFramework, restore: true, iterationCancellationToken); + if (evaluationResult != null) + { + break; + } + } + + _ = await fileWatcher.WaitForFileChangeAsync( + acceptChange: AcceptChange, + startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), + shutdownCancellationToken); + } // additional files/directories may have been added: - evaluationResult.WatchFiles(fileWatcher); + evaluationResult.WatchFileItems(fileWatcher); await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); @@ -442,7 +486,12 @@ async Task> CaptureChangedFilesSnapshot(IReadOnlyLis else if (mainRunningProject?.IsRestarting != true && !suppressWaitForFileChange) { using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); - await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); + await WaitForFileChangeBeforeRestarting( + fileWatcher, + evaluationResult, + projectGraph, + rootProjectsBuildResult?.Success == true, + shutdownOrForcedRestartSource.Token); } } } @@ -489,7 +538,7 @@ private void AnalyzeFileChanges( { // 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) + if (changedFiles.Select(f => f.Item.FilePath).FirstOrDefault(path => evaluationResult.ProjectGraph.BuildFiles.Contains(path) || MatchesBuildFile(path)) is { } firstBuildFilePath) { _context.Logger.Log(MessageDescriptor.ProjectChangeTriggeredReEvaluation, firstBuildFilePath); evaluationRequired = true; @@ -678,28 +727,28 @@ private async ValueTask DeployProjectDependenciesAsync(EvaluationResult evaluati await Task.WhenAll(copyTasks); } - private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) + private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, LoadedProjectGraph? projectGraph, bool buildSucceeded, CancellationToken cancellationToken) { + var messageDescriptor = buildSucceeded ? MessageDescriptor.WaitingForFileChangeBeforeRestarting : MessageDescriptor.FixBuildError; + if (evaluationResult != null) { - if (!fileWatcher.WatchingDirectories) - { - evaluationResult.WatchFiles(fileWatcher); - } - _ = await fileWatcher.WaitForFileChangeAsync( evaluationResult.Files, - startedWatching: () => _context.Logger.Log(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + startedWatching: () => _context.Logger.Log(messageDescriptor), cancellationToken); } else { - // evaluation cancelled - watch for any changes in the directory trees containing root projects or entry-point files: - fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true); + // build failed or was cancelled - watch for any changes in the directory trees containing root projects or entry-point files: + if (projectGraph == null) + { + fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true); + } _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: AcceptChange, - startedWatching: () => _context.Logger.Log(MessageDescriptor.WaitingForFileChangeBeforeRestarting), + startedWatching: () => _context.Logger.Log(messageDescriptor), cancellationToken); } } @@ -722,7 +771,7 @@ private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult) } // changes in *.*proj, *.props, *.targets: - if (evaluationResult.BuildFiles.Contains(path)) + if (evaluationResult.ProjectGraph.BuildFiles.Contains(path)) { return true; } @@ -872,48 +921,107 @@ static string GetPluralMessage(ChangeKind kind) }; } - private async ValueTask EvaluateProjectGraphAsync(bool restore, CancellationToken cancellationToken) - { - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - _context.Logger.Log(MessageDescriptor.LoadingProjects); - var stopwatch = Stopwatch.StartNew(); - - var result = await EvaluationResult.TryCreateAsync(_designTimeBuildGraphFactory, _context.Options, _context.EnvironmentOptions, restore, cancellationToken); + private LoadedProjectGraph? TryLoadProjectGraph(CancellationToken cancellationToken) + => _designTimeBuildGraphFactory.TryLoadProjectGraph(projectGraphRequired: true, cancellationToken); + + private ValueTask TryEvaluateProjectGraphAsync(LoadedProjectGraph projectGraph, string? mainProjectTargetFramework, bool restore, CancellationToken cancellationToken) + => EvaluationResult.TryCreateAsync( + projectGraph, + _context.BuildLogger, + _context.Options, + _context.EnvironmentOptions, + mainProjectTargetFramework, + restore, + cancellationToken); - if (result != null) - { - _context.Logger.Log(MessageDescriptor.LoadedProjects, result.ProjectGraph.Graph.ProjectNodes.Count, stopwatch.Elapsed.TotalSeconds); - return result; - } + private enum BuildAction + { + RestoreOnly, + RestoreAndBuild, + BuildOnly, + } - await FileWatcher.WaitForFileChangeAsync( - _context.RootProjects.Select(static p => p.ProjectOrEntryPointFilePath), - _context.Logger, - _context.EnvironmentOptions, - startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError), - cancellationToken); - } + // internal for testing + internal sealed class BuildProjectsResult(string? mainProjectTargetFramework, LoadedProjectGraph? projectGraph, bool success) + { + public string? MainProjectTargetFramework { get; } = mainProjectTargetFramework; + public LoadedProjectGraph? ProjectGraph { get; } = projectGraph; + public bool Success { get; } = success; } // internal for testing - internal async Task BuildProjectsAsync(ImmutableArray projects, CancellationToken cancellationToken) + internal async Task BuildProjectsAsync( + ImmutableArray projects, + FileWatcher fileWatcher, + ProjectOptions? mainProjectOptions, + Func, CancellationToken, ValueTask>? frameworkSelector, + CancellationToken cancellationToken) { Debug.Assert(projects.Any()); + LoadedProjectGraph? projectGraph = null; + var targetFramework = mainProjectOptions?.TargetFramework; + _context.Logger.Log(MessageDescriptor.BuildStartedNotification, projects); - var success = await BuildAsync(); - _context.Logger.Log(MessageDescriptor.BuildCompletedNotification, (projects, success)); - return success; + // pause accumulating file changes during build: + fileWatcher.SuppressEvents = true; + try + { + var success = await BuildWithFrameworkSelectionAsync(); + _context.Logger.Log(MessageDescriptor.BuildCompletedNotification, (projects, success)); + return new BuildProjectsResult(targetFramework, projectGraph, success); + } + finally + { + fileWatcher.SuppressEvents = false; + } + + async ValueTask BuildWithFrameworkSelectionAsync() + { + if (mainProjectOptions == null || + frameworkSelector == null || + targetFramework != null || + !mainProjectOptions.Representation.IsProjectFile) + { + return await BuildAsync(BuildAction.RestoreAndBuild, targetFramework); + } + + if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework: null)) + { + return false; + } + + // load project graph after restore so that props and targets files from packages are imported: + projectGraph = TryLoadProjectGraph(cancellationToken); + if (projectGraph == null) + { + return false; + } - async Task BuildAsync() + var rootProject = projectGraph.Graph.GraphRoots.Single().ProjectInstance; + if (rootProject.GetTargetFramework() is var framework and not "") + { + targetFramework = framework; + } + else if (rootProject.GetTargetFrameworks() is var frameworks and not []) + { + targetFramework = await frameworkSelector(frameworks, cancellationToken); + } + else + { + _context.BuildLogger.LogError("Project '{Path}' does not specify a target framework.", rootProject.FullPath); + return false; + } + + return await BuildAsync(BuildAction.BuildOnly, targetFramework); + } + + async Task BuildAsync(BuildAction action, string? targetFramework) { if (projects is [var singleProject]) { - return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, cancellationToken); + return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, targetFramework, action, cancellationToken); } // TODO: workaround for https://github.com/dotnet/sdk/issues/51311 @@ -922,7 +1030,7 @@ async Task BuildAsync() if (projectPaths is [var singleProjectPath]) { - if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, cancellationToken)) + if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, targetFramework, action, cancellationToken)) { return false; } @@ -942,7 +1050,7 @@ async Task BuildAsync() try { - if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, cancellationToken)) + if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, targetFramework, action, cancellationToken)) { return false; } @@ -963,7 +1071,7 @@ async Task BuildAsync() // To maximize parallelism of building dependencies, build file-based projects after all physical projects: foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!)) { - if (!await BuildFileOrProjectOrSolutionAsync(file, cancellationToken)) + if (!await BuildFileOrProjectOrSolutionAsync(file, targetFramework, action, cancellationToken)) { return false; } @@ -973,8 +1081,31 @@ async Task BuildAsync() } } - private async Task BuildFileOrProjectOrSolutionAsync(string path, CancellationToken cancellationToken) + private async Task BuildFileOrProjectOrSolutionAsync(string path, string? targetFramework, BuildAction action, CancellationToken cancellationToken) { + var arguments = new List + { + action is BuildAction.RestoreOnly ? "restore" : "build", + path + }; + + arguments.AddRange(_context.BuildArguments); + + if (action != BuildAction.RestoreOnly && targetFramework != null) + { + arguments.Add("--framework"); + arguments.Add(targetFramework); + } + + if (action == BuildAction.BuildOnly) + { + arguments.Add("--no-restore"); + } + else if (action == BuildAction.RestoreOnly) + { + arguments.Add("-consoleLoggerParameters:NoSummary"); + } + List? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null; var processSpec = new ProcessSpec { @@ -995,17 +1126,25 @@ private async Task BuildFileOrProjectOrSolutionAsync(string path, Cancella : null, // pass user-specified build arguments last to override defaults: - Arguments = ["build", path, .. _context.BuildArguments] + Arguments = arguments }; - _context.BuildLogger.Log(MessageDescriptor.Building, path); + _context.BuildLogger.Log(action == BuildAction.RestoreOnly ? MessageDescriptor.Restoring : MessageDescriptor.Building, path); var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0; if (capturedOutput != null) { // To avoid multiple status messages, only log the status if the output of `dotnet build` is not being streamed to the console: - _context.BuildLogger.Log(success ? MessageDescriptor.BuildSucceeded : MessageDescriptor.BuildFailed, path); + _context.BuildLogger.Log( + (action, success) switch + { + (BuildAction.RestoreOnly, true) => MessageDescriptor.RestoreSucceeded, + (BuildAction.RestoreOnly, false) => MessageDescriptor.RestoreFailed, + (_, true) => MessageDescriptor.BuildSucceeded, + (_, false) => MessageDescriptor.BuildFailed, + }, + path); BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success); } diff --git a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs index 4e31e8cced3a..b3cb5b211908 100644 --- a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs +++ b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs @@ -37,7 +37,7 @@ public CompilationHandler CompilationHandler RestartOperation restartOperation, CancellationToken cancellationToken) { - var projectNode = projectGraph.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, context.TargetFramework); + var projectNode = projectGraph.TryGetProjectNode(projectOptions.Representation.ProjectGraphPath, projectOptions.TargetFramework); if (projectNode == null) { // error already reported @@ -114,6 +114,12 @@ private static IReadOnlyList GetProcessArguments(ProjectOptions projectO "--no-build" }; + if (projectOptions.TargetFramework != null) + { + arguments.Add("--framework"); + arguments.Add(projectOptions.TargetFramework); + } + foreach (var (name, value) in environmentBuilder) { arguments.Add("-e"); diff --git a/src/Dotnet.Watch/Watch/Process/RunningProject.cs b/src/Dotnet.Watch/Watch/Process/RunningProject.cs index c39dd4e541ae..5ee9580d2780 100644 --- a/src/Dotnet.Watch/Watch/Process/RunningProject.cs +++ b/src/Dotnet.Watch/Watch/Process/RunningProject.cs @@ -28,6 +28,9 @@ internal sealed class RunningProject( public ImmutableArray ManagedCodeUpdateCapabilities => managedCodeUpdateCapabilities; public RunningProcess Process => process; + public string GetTargetFramework() + => projectNode.ProjectInstance.GetTargetFramework(); + /// /// Set to true when the process termination is being requested so that it can be auto-restarted. /// diff --git a/src/Dotnet.Watch/Watch/UI/ConsoleInputReader.cs b/src/Dotnet.Watch/Watch/UI/ConsoleInputReader.cs index 233d320765ba..9f1044b24ae4 100644 --- a/src/Dotnet.Watch/Watch/UI/ConsoleInputReader.cs +++ b/src/Dotnet.Watch/Watch/UI/ConsoleInputReader.cs @@ -9,11 +9,11 @@ internal sealed class ConsoleInputReader(IConsole console, LogLevel logLevel, bo { private readonly object _writeLock = new(); - public async Task GetKeyAsync(string prompt, Func validateInput, CancellationToken cancellationToken) + public async Task GetKeyAsync(string prompt, Func validateInput, CancellationToken cancellationToken) { if (logLevel > LogLevel.Information) { - return ConsoleKey.Escape; + return new ConsoleKeyInfo('\u001b', ConsoleKey.Escape, shift: false, alt: false, control: false); } var questionMark = suppressEmojis ? "?" : "โ”"; @@ -21,7 +21,7 @@ public async Task GetKeyAsync(string prompt, Func(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); console.KeyPressed += KeyPressed; WriteLine($" {questionMark} {prompt}"); @@ -52,7 +52,7 @@ void KeyPressed(ConsoleKeyInfo key) if (validateInput(key)) { WriteLine(keyDisplay); - tcs.TrySetResult(key.Key); + tcs.TrySetResult(key); } else { diff --git a/src/Dotnet.Watch/Watch/UI/IReporter.cs b/src/Dotnet.Watch/Watch/UI/IReporter.cs index 09bcb2feba1f..15271f489de5 100644 --- a/src/Dotnet.Watch/Watch/UI/IReporter.cs +++ b/src/Dotnet.Watch/Watch/UI/IReporter.cs @@ -184,8 +184,8 @@ public static MessageDescriptor GetDescriptor(EventId id) // predefined messages used for testing: public static readonly MessageDescriptor CommandDoesNotSupportHotReload = Create("Command '{0}' does not support Hot Reload.", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor HotReloadDisabledByCommandLineSwitch = Create("Hot Reload disabled by command line switch.", Emoji.HotReload, LogLevel.Debug); - public static readonly MessageDescriptor HotReloadSessionStartingNotification = CreateNotification(); public static readonly MessageDescriptor HotReloadSessionStarted = Create("Hot reload session started.", Emoji.HotReload, LogLevel.Debug); + public static readonly MessageDescriptor RuntimeProcessLauncherCreatedNotification = CreateNotification(); public static readonly MessageDescriptor ProjectsRebuilt = Create("Projects rebuilt ({0})", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor ProjectsRestarted = Create("Projects restarted ({0})", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor> RestartingProjectsNotification = CreateNotification>(); @@ -200,6 +200,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 '{BundleFile}' not found.", Emoji.Warning, LogLevel.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); @@ -217,7 +219,7 @@ public static MessageDescriptor GetDescriptor(EventId id) public static readonly MessageDescriptor ApplyUpdate_Error = Create("{0}", Emoji.Error, LogLevel.Error); public static readonly MessageDescriptor ApplyUpdate_Warning = Create("{0}", Emoji.Warning, LogLevel.Warning); public static readonly MessageDescriptor ApplyUpdate_Verbose = Create("{0}", Emoji.Default, LogLevel.Debug); - public static readonly MessageDescriptor<(string, string)> ApplyUpdate_AutoVerbose = Create<(string, string)>("{0}{1}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor ApplyUpdate_AutoVerbose = Create("{0}", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor ApplyUpdate_ChangingEntryPoint = Create("{0} Press \"Ctrl + R\" to restart.", Emoji.Warning, LogLevel.Warning); public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, LogLevel.Debug); @@ -273,10 +275,14 @@ public static MessageDescriptor GetDescriptor(EventId id) public static readonly MessageDescriptor LoadingProjects = Create("Loading projects ...", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor<(int, double)> LoadedProjects = Create<(int, double)>("Loaded {0} project(s) in {1:0.0}s.", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor Restoring = Create("Restoring {0} ...", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor RestoreFailed = Create("Restore failed: {0}", Emoji.Default, LogLevel.Debug); + public static readonly MessageDescriptor RestoreSucceeded = Create("Restore succeeded: {0}", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor> BuildStartedNotification = CreateNotification>(); public static readonly MessageDescriptor<(IEnumerable projects, bool success)> BuildCompletedNotification = CreateNotification<(IEnumerable projects, bool success)>(); + public static readonly MessageDescriptor ManifestFileNotFound = Create(LogEvents.ManifestFileNotFound, Emoji.Default); } internal sealed class MessageDescriptor(string? format, Emoji emoji, LogLevel level, EventId id) diff --git a/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs b/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs index 46afed53a210..8827b6a1f9a4 100644 --- a/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs +++ b/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs @@ -70,6 +70,7 @@ private async Task ListenToStandardInputAsync() CtrlR => new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true), >= 'a' and <= 'z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'a'), shift: false, alt: false, control: false), >= 'A' and <= 'Z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'A'), shift: true, alt: false, control: false), + >= '0' and <= '9' => new ConsoleKeyInfo(c, ConsoleKey.NumPad0 + (c - '0'), shift: false, alt: false, control: false), _ => default }; diff --git a/src/Dotnet.Watch/Watch/UI/RestartPrompt.cs b/src/Dotnet.Watch/Watch/UI/RestartPrompt.cs index 412f4b000cc5..c9f28c80d9f2 100644 --- a/src/Dotnet.Watch/Watch/UI/RestartPrompt.cs +++ b/src/Dotnet.Watch/Watch/UI/RestartPrompt.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class RestartPrompt(ILogger logger, ConsoleInputReader requester, bool? noPrompt) + internal sealed class RestartPrompt(ILogger logger, ConsoleInputReader inputReader, bool? noPrompt) { public bool? AutoRestartPreference { get; private set; } = noPrompt; @@ -17,12 +17,12 @@ public async ValueTask WaitForRestartConfirmationAsync(string question, Ca return AutoRestartPreference.Value; } - var key = await requester.GetKeyAsync( + var keyInfo = await inputReader.GetKeyAsync( $"{question} Yes (y) / No (n) / Always (a) / Never (v)", AcceptKey, cancellationToken); - switch (key) + switch (keyInfo.Key) { case ConsoleKey.Escape: case ConsoleKey.Y: diff --git a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs new file mode 100644 index 000000000000..0cb467544a49 --- /dev/null +++ b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs @@ -0,0 +1,44 @@ +๏ปฟ// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class TargetFrameworkSelectionPrompt(ConsoleInputReader inputReader) +{ + public IReadOnlyList? PreviousTargetFrameworks { get; set; } + public string? PreviousSelection { get; set; } + + public async ValueTask SelectAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken) + { + var orderedTargetFrameworks = targetFrameworks.Order().ToArray(); + + if (PreviousSelection != null && PreviousTargetFrameworks?.SequenceEqual(orderedTargetFrameworks, StringComparer.OrdinalIgnoreCase) == true) + { + return PreviousSelection; + } + + PreviousTargetFrameworks = orderedTargetFrameworks; + + var keyInfo = await inputReader.GetKeyAsync( + $"Select target framework:{Environment.NewLine}{string.Join(Environment.NewLine, targetFrameworks.Select((tfm, i) => $"{i + 1}) {tfm}"))}", + AcceptKey, + cancellationToken); + + _ = TryGetIndex(keyInfo, out var index); + return PreviousSelection = targetFrameworks[index]; + + bool TryGetIndex(ConsoleKeyInfo info, out int index) + { + index = info.KeyChar - '1'; + return index >= 0 && index < targetFrameworks.Count; + } + + bool AcceptKey(ConsoleKeyInfo info) + => info is { Modifiers: ConsoleModifiers.None } && TryGetIndex(info, out _); + } +} diff --git a/src/Dotnet.Watch/dotnet-watch/CommandLine/CommandLineOptions.cs b/src/Dotnet.Watch/dotnet-watch/CommandLine/CommandLineOptions.cs index afee098997cd..9ea10ff71285 100644 --- a/src/Dotnet.Watch/dotnet-watch/CommandLine/CommandLineOptions.cs +++ b/src/Dotnet.Watch/dotnet-watch/CommandLine/CommandLineOptions.cs @@ -5,6 +5,7 @@ using System.CommandLine; using System.CommandLine.Parsing; using System.Data; +using Microsoft.DotNet.Cli; using Microsoft.DotNet.Cli.CommandLine; using Microsoft.DotNet.Cli.Commands; using Microsoft.DotNet.Cli.Commands.Build; @@ -65,7 +66,10 @@ internal sealed class CommandLineOptions // determine subcommand: var command = GetSubcommand(parseResult, out bool isExplicitCommand); - var buildOptions = command.Options.Where(o => o.ForwardingFunction is not null); + + // Options that the subcommand forwards to build command. + // Exclude --framework option as it is passed to `dotnet build` and `dotnet run` explicitly by the watcher. + var buildOptions = command.Options.Where(o => o.ForwardingFunction is not null && o.Name != CommonOptions.FrameworkOptionName); foreach (var buildOption in buildOptions) { @@ -112,15 +116,16 @@ internal sealed class CommandLineOptions out var binLogPath); // We assume that forwarded options, if any, are intended for dotnet build. - var buildArguments = buildOptions.Select(option => option.ForwardingFunction!(parseResult)).SelectMany(args => args).ToList(); + var buildArguments = buildOptions + .Select(option => option.ForwardingFunction!(parseResult)) + .SelectMany(args => args) + .ToList(); if (binLogToken != null) { buildArguments.Add(binLogToken); } - var targetFrameworkOption = (Option?)buildOptions.SingleOrDefault(option => option.Name == "--framework"); - var logLevel = parseResult.GetValue(definition.VerboseOption) ? LogLevel.Debug : parseResult.GetValue(definition.QuietOption) @@ -150,7 +155,7 @@ internal sealed class CommandLineOptions FilePath = parseResult.GetValue(definition.FileOption), LaunchProfileName = launchProfile, BuildArguments = buildArguments, - TargetFramework = targetFrameworkOption != null ? parseResult.GetValue(targetFrameworkOption) : null, + TargetFramework = parseResult.GetValue(definition.FrameworkOption), }; } @@ -196,7 +201,7 @@ private static IReadOnlyList GetCommandArguments( { continue; } - + // forward forwardable option if the subcommand supports it: if (!command.Options.Any(option => option.Name == optionResult.Option.Name)) { @@ -351,6 +356,7 @@ public ProjectOptions GetMainProjectOptions(ProjectRepresentation project, strin IsMainProject = true, Representation = project, WorkingDirectory = workingDirectory, + TargetFramework = TargetFramework, Command = Command, CommandArguments = CommandArguments, LaunchEnvironmentVariables = [], diff --git a/src/Dotnet.Watch/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs b/src/Dotnet.Watch/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs index f8ccd2535555..71bbfa3bd255 100644 --- a/src/Dotnet.Watch/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs +++ b/src/Dotnet.Watch/dotnet-watch/CommandLine/DotnetWatchCommandDefinition.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Cli; using Microsoft.DotNet.Cli.CommandLine; namespace Microsoft.DotNet.Watch; @@ -15,12 +16,53 @@ internal sealed class DotnetWatchCommandDefinition : RootCommand public readonly Option NoHotReloadOption = new("--no-hot-reload") { Description = Resources.Help_NoHotReload, Arity = ArgumentArity.Zero }; public readonly Option NonInteractiveOption = new("--non-interactive") { Description = Resources.Help_NonInteractive, Arity = ArgumentArity.Zero }; + /// + /// Specifies target framework. The watcher passes the value explicitly instead of forwarding the subcommand's --framework option. + /// + public readonly Option FrameworkOption = new(CommonOptions.FrameworkOptionName, "-f") + { + Description = CommandDefinitionStrings.BuildFrameworkOptionDescription, + HelpName = CommandDefinitionStrings.FrameworkArgumentName, + }; + // Options we need to know about. They are passed through to the subcommand if the subcommand supports them. - public readonly Option ShortProjectOption = new("-p") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - public readonly Option LongProjectOption = new("--project") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - public readonly Option FileOption = new("--file") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") { Hidden = true, Arity = ArgumentArity.ZeroOrOne, AllowMultipleArgumentsPerToken = false }; - public readonly Option NoLaunchProfileOption = new("--no-launch-profile") { Hidden = true, Arity = ArgumentArity.Zero }; + + public readonly Option ShortProjectOption = new("-p") + { + Hidden = true, + Arity = ArgumentArity.ZeroOrOne, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option LongProjectOption = new("--project") + { + Description = CommandDefinitionStrings.CmdProjectDescriptionFormat, + HelpName = CommandDefinitionStrings.CommandOptionProjectHelpName, + Arity = ArgumentArity.ZeroOrOne, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option FileOption = new("--file") + { + Description = CommandDefinitionStrings.CommandOptionFileDescription, + HelpName = CommandDefinitionStrings.CommandOptionFileHelpName, + Arity = ArgumentArity.ZeroOrOne, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option LaunchProfileOption = new("--launch-profile", "-lp") + { + Description = CommandDefinitionStrings.CommandOptionLaunchProfileDescription, + HelpName = CommandDefinitionStrings.CommandOptionLaunchProfileHelpName, + Arity = ArgumentArity.ZeroOrOne, + AllowMultipleArgumentsPerToken = false + }; + + public readonly Option NoLaunchProfileOption = new("--no-launch-profile") + { + Description = CommandDefinitionStrings.CommandOptionNoLaunchProfileDescription, + Arity = ArgumentArity.Zero + }; public DotnetWatchCommandDefinition() : base(Resources.Help) @@ -37,6 +79,7 @@ public DotnetWatchCommandDefinition() Options.Add(ListOption); Options.Add(NoHotReloadOption); Options.Add(NonInteractiveOption); + Options.Add(FrameworkOption); Options.Add(LongProjectOption); Options.Add(ShortProjectOption); @@ -74,5 +117,6 @@ public bool IsWatchOption(Option option) option == VerboseOption || option == ListOption || option == NoHotReloadOption || - option == NonInteractiveOption; + option == NonInteractiveOption || + option == FrameworkOption; } diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs index 0e6678dca900..0bfc3c3afc19 100644 --- a/src/Dotnet.Watch/dotnet-watch/Program.cs +++ b/src/Dotnet.Watch/dotnet-watch/Program.cs @@ -310,7 +310,6 @@ internal DotNetWatchContext CreateContext(ProcessRunner processRunner) MainProjectOptions = mainProjectOptions, RootProjects = [mainProjectOptions.Representation], BuildArguments = options.BuildArguments, - TargetFramework = options.TargetFramework, BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), BrowserLauncher = new BrowserLauncher(logger, processOutputReporter, environmentOptions), }; diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/BuildEvaluator.cs b/src/Dotnet.Watch/dotnet-watch/Watch/BuildEvaluator.cs index e51ff21d3d56..2df91329873f 100644 --- a/src/Dotnet.Watch/dotnet-watch/Watch/BuildEvaluator.cs +++ b/src/Dotnet.Watch/dotnet-watch/Watch/BuildEvaluator.cs @@ -47,7 +47,7 @@ protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() return new( MainProjectOptions.Representation.PhysicalPath, - _context.TargetFramework, + MainProjectOptions.TargetFramework, _context.BuildArguments, _context.ProcessRunner, _context.BuildLogger, @@ -56,6 +56,7 @@ protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() public IReadOnlyList GetProcessArguments(int iteration) { + var noRestore = false; if (!_context.EnvironmentOptions.SuppressMSBuildIncrementalism && iteration > 0 && MainProjectOptions.IsCodeExecutionCommand) @@ -67,11 +68,29 @@ public IReadOnlyList GetProcessArguments(int iteration) else { _context.Logger.LogDebug("Modifying command to use --no-restore"); - return [MainProjectOptions.Command, "--no-restore", .. MainProjectOptions.CommandArguments]; + noRestore = true; } } - return [MainProjectOptions.Command, .. MainProjectOptions.CommandArguments]; + var arguments = new List() + { + MainProjectOptions.Command + }; + + if (noRestore) + { + arguments.Add("--no-restore"); + } + + if (MainProjectOptions.TargetFramework != null) + { + arguments.Add("--framework"); + arguments.Add(MainProjectOptions.TargetFramework); + } + + arguments.AddRange(MainProjectOptions.CommandArguments); + + return arguments; } public async ValueTask EvaluateAsync(ChangedFile? changedFile, CancellationToken cancellationToken) diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/StaticFileHandler.cs b/src/Dotnet.Watch/dotnet-watch/Watch/StaticFileHandler.cs index 38335764b3af..afcb52326e03 100644 --- a/src/Dotnet.Watch/dotnet-watch/Watch/StaticFileHandler.cs +++ b/src/Dotnet.Watch/dotnet-watch/Watch/StaticFileHandler.cs @@ -31,14 +31,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f foreach (var containingProjectPath in file.ContainingProjectPaths) { - if (!projectGraph.Map.TryGetValue(containingProjectPath, out var projectNodes)) - { - // Shouldn't happen. - logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); - return allFilesHandled; - } - - foreach (var projectNode in projectNodes) + foreach (var projectNode in projectGraph.GetProjectNodes(containingProjectPath)) { if (browserConnector.TryGetRefreshServer(projectNode, out var refreshServer)) { diff --git a/test/dotnet-watch.Tests/Browser/BrowserTests.cs b/test/dotnet-watch.Tests/Browser/BrowserTests.cs index d2b82f8e2c87..56141471a75c 100644 --- a/test/dotnet-watch.Tests/Browser/BrowserTests.cs +++ b/test/dotnet-watch.Tests/Browser/BrowserTests.cs @@ -34,7 +34,7 @@ public async Task BrowserDiagnostics() App.UseTestBrowser(); var url = $"http://localhost:{TestOptions.GetTestPort()}"; - var tfm = ToolsetInfo.CurrentTargetFramework; + var projectDisplay = $"RazorApp ({ToolsetInfo.CurrentTargetFramework})"; App.Start(testAsset, ["--urls", url], relativeProjectDirectory: "RazorApp", testFlags: TestFlags.ReadKeyFromStdin); @@ -57,7 +57,7 @@ public async Task BrowserDiagnostics() public virtual int F() => 1; """)); - var errorMessage = $"{homePagePath}(13,9): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."; + var errorMessage = $"[{projectDisplay}] {homePagePath}(13,9): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."; var jsonErrorMessage = JsonSerializer.Serialize(errorMessage); await App.WaitUntilOutputContains(errorMessage); @@ -72,7 +72,7 @@ await App.WaitUntilOutputContains($$""" App.SendKey('a'); // browser page is reloaded when the app restarts: - await App.WaitUntilOutputContains(MessageDescriptor.ReloadingBrowser, $"RazorApp ({tfm})"); + await App.WaitUntilOutputContains(MessageDescriptor.ReloadingBrowser, projectDisplay); // browser page was reloaded after the app restarted: await App.WaitUntilOutputContains(""" @@ -89,8 +89,8 @@ await App.WaitUntilOutputContains(""" // another rude edit: UpdateSourceFile(homePagePath, src => src.Replace("public virtual int F() => 1;", "/* member placeholder */")); - errorMessage = $"{homePagePath}(11,5): error ENC0033: Deleting method 'F()' requires restarting the application."; - await App.WaitUntilOutputContains("[auto-restart] " + errorMessage); + errorMessage = $"[{projectDisplay}] [auto-restart] {homePagePath}(11,5): error ENC0033: Deleting method 'F()' requires restarting the application."; + await App.WaitUntilOutputContains(errorMessage); await App.WaitUntilOutputContains($$""" ๐Ÿงช Received: {"type":"ReportDiagnostics","diagnostics":["Restarting application to apply changes ..."]} diff --git a/test/dotnet-watch.Tests/Build/EvaluationResultTests.cs b/test/dotnet-watch.Tests/Build/EvaluationResultTests.cs new file mode 100644 index 000000000000..ff39dfeeaadd --- /dev/null +++ b/test/dotnet-watch.Tests/Build/EvaluationResultTests.cs @@ -0,0 +1,169 @@ +๏ปฟ// 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.Construction; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class EvaluationResultTests +{ + public ProjectGraph CreateGraph(TestDirectory testDir, params (string projectName, string[] targetFrameworks, string[] referencedProjects)[] projects) + { + var projectDefinitions = new Dictionary(); + foreach (var (projectName, targetFrameworks, referencedProjects) in projects) + { + projectDefinitions[projectName] = (targetFrameworks, referencedProjects); + } + + var allReferencedProjects = projects + .SelectMany(p => p.referencedProjects) + .ToHashSet(); + + var entryPoints = projects + .Where(p => !allReferencedProjects.Contains(p.projectName)) + .Select(p => new ProjectGraphEntryPoint(Path.Combine(testDir.Path, p.projectName))); + + return new ProjectGraph( + entryPoints, + ProjectCollection.GlobalProjectCollection, + (path, globalProperties, collection) => + { + var (targetFrameworks, references) = projectDefinitions[Path.GetFileName(path)]; + + var projectXml = ProjectRootElement.Create(collection); + projectXml.FullPath = Path.Combine(testDir.Path, path); + projectXml.Sdk = "Microsoft.NET.Sdk"; + + var propertyGroup = projectXml.AddPropertyGroup(); + if (targetFrameworks.Length == 1) + { + propertyGroup.AddProperty("TargetFramework", targetFrameworks[0]); + } + else if (targetFrameworks.Length > 1) + { + propertyGroup.AddProperty("TargetFrameworks", string.Join(";", targetFrameworks)); + } + + if (references.Length > 0) + { + var itemGroup = projectXml.AddItemGroup(); + foreach (var reference in references) + { + itemGroup.AddItem("ProjectReference", reference); + } + } + + return new ProjectInstance( + projectXml, + globalProperties, + toolsVersion: null, + subToolsetVersion: null, + collection); + }); + } + + [Theory] + [InlineData(null)] + [InlineData("net9.0")] + public void CreateDesignTimeBuildRequests_SingleTfm(string? mainTfm) + { + var testDir = TestAssetsManager.CreateTestDirectory(identifiers: [mainTfm]); + + var graph = CreateGraph( + testDir, + ("main.csproj", ["net9.0"], [])); + + var requests = EvaluationResult.CreateDesignTimeBuildRequests(graph, mainProjectTargetFramework: mainTfm, suppressStaticWebAssets: false); + + AssertEx.SequenceEqual(["main (net9.0)"], requests.Select(r => r.ProjectInstance.GetDisplayName())); + } + + [Theory] + [InlineData(null)] + [InlineData("net9.0")] + public void CreateDesignTimeBuildRequests_SingleTfm_WithDependencies(string? mainTfm) + { + var testDir = TestAssetsManager.CreateTestDirectory(identifiers: [mainTfm]); + + var graph = CreateGraph( + testDir, + ("main.csproj", ["net9.0"], ["dep.csproj"]), + ("dep.csproj", ["netstandard2.0"], [])); + + var requests = EvaluationResult.CreateDesignTimeBuildRequests(graph, mainProjectTargetFramework: mainTfm, suppressStaticWebAssets: false); + + AssertEx.SequenceEqual( + [ + "dep (netstandard2.0)", + "main (net9.0)", + ], requests.Select(r => r.ProjectInstance.GetDisplayName())); + } + + [Theory] + [InlineData(null)] + [InlineData("net9.0")] + public void CreateDesignTimeBuildRequests_SingleTfm_WithMultiTargetedDependencies(string? mainTfm) + { + var testDir = TestAssetsManager.CreateTestDirectory(identifiers: [mainTfm]); + + var graph = CreateGraph( + testDir, + ("main.csproj", ["net9.0"], ["dep.csproj"]), + ("dep.csproj", ["netstandard2.0", "net8.0"], [])); + + var requests = EvaluationResult.CreateDesignTimeBuildRequests(graph, mainProjectTargetFramework: mainTfm, suppressStaticWebAssets: false); + + AssertEx.SequenceEqual( + [ + "dep (net8.0)", + "dep (netstandard2.0)", + "main (net9.0)", + ], requests.Select(r => r.ProjectInstance.GetDisplayName())); + } + + [Fact] + public void CreateDesignTimeBuildRequests_MultiTfm_WithDependencies_NoMainTfm() + { + var testDir = TestAssetsManager.CreateTestDirectory(); + + var graph = CreateGraph( + testDir, + ("main.csproj", ["net8.0", "net9.0"], ["dep.csproj"]), + ("dep.csproj", ["netstandard2.0", "net8.0"], [])); + + var requests = EvaluationResult.CreateDesignTimeBuildRequests(graph, mainProjectTargetFramework: null, suppressStaticWebAssets: false); + + AssertEx.SequenceEqual( + [ + "dep (net8.0)", + "dep (netstandard2.0)", + "main (net9.0)", + "main (net8.0)", + ], requests.Select(r => r.ProjectInstance.GetDisplayName())); + } + + [Fact] + public void CreateDesignTimeBuildRequests_MultiTfm_WithDependencies_MainTfm() + { + var testDir = TestAssetsManager.CreateTestDirectory(); + + var graph = CreateGraph( + testDir, + ("main.csproj", ["net8.0", "net9.0"], ["dep.csproj"]), + ("dep.csproj", ["netstandard2.0", "net8.0", "net9.0"], [])); + + var requests = EvaluationResult.CreateDesignTimeBuildRequests(graph, mainProjectTargetFramework: "net8.0", suppressStaticWebAssets: false); + + // main (net9.0) should not be built: + AssertEx.SequenceEqual( + [ + "dep (net9.0)", + "dep (net8.0)", + "dep (netstandard2.0)", + "main (net8.0)", + ], requests.Select(r => r.ProjectInstance.GetDisplayName())); + } +} diff --git a/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs b/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs index 237c860329c8..fbcbb8404f8a 100644 --- a/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs +++ b/test/dotnet-watch.Tests/Build/ProjectGraphFactoryTests.cs @@ -20,7 +20,7 @@ public void RegularProject() var projectPath = Path.Combine(testAsset.Path, "WatchNoDepsApp.csproj"); var projectRepr = new ProjectRepresentation(projectPath, entryPointFilePath: null); - var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + var factory = new ProjectGraphFactory([projectRepr], virtualProjectTargetFramework: null, buildProperties: [], _testLogger); var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); Assert.NotNull(graph); @@ -40,7 +40,7 @@ public void VirtualProject() """); var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); - var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + var factory = new ProjectGraphFactory([projectRepr], virtualProjectTargetFramework: null, buildProperties: [], _testLogger); var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); Assert.NotNull(graph); @@ -60,7 +60,7 @@ public void VirtualProject_Error() """); var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); - var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + var factory = new ProjectGraphFactory([projectRepr], virtualProjectTargetFramework: null, buildProperties: [], _testLogger); var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); Assert.Null(graph); @@ -92,7 +92,7 @@ public void VirtualProject_ProjectDirective() """); var projectRepr = new ProjectRepresentation(projectPath: null, entryPointFilePath); - var factory = new ProjectGraphFactory([projectRepr], targetFramework: null, buildProperties: [], _testLogger); + var factory = new ProjectGraphFactory([projectRepr], virtualProjectTargetFramework: null, buildProperties: [], _testLogger); var graph = factory.TryLoadProjectGraph(projectGraphRequired: true, CancellationToken.None); Assert.NotNull(graph); diff --git a/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs b/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs index 8b34d225d718..7270387f8dff 100644 --- a/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/CommandLineOptionsTests.cs @@ -316,11 +316,13 @@ public void OptionsSpecifiedBeforeOrAfterRun(bool afterRun) Assert.Equal("P", options.ProjectPath); Assert.Equal("F", options.TargetFramework); - // the forwarding function of --property property joins the properties with `:`: - AssertEx.SequenceEqual(["--property:TargetFramework=F", "--property:P1=V1", "--property:P2=V2", NugetInteractiveProperty], options.BuildArguments); + // The forwarding function of --property property joins the properties with `:` + // --framework is not forwarded as property. + AssertEx.SequenceEqual(["--property:P1=V1", "--property:P2=V2", NugetInteractiveProperty], options.BuildArguments); - // it's ok to keep the two arguments and not to join them with `:` since `run` command handles these options correctly - AssertEx.SequenceEqual(["--project", "P", "--framework", "F", "--property", "P1=V1", "--property", "P2=V2"], options.CommandArguments); + // It's ok to keep the two arguments and not to join them with `:` since `run` command handles these options correctly + // --framework is not forwarded, it will be specified explicitly. + AssertEx.SequenceEqual(["--project", "P", "--property", "P1=V1", "--property", "P2=V2"], options.CommandArguments); } public enum ArgPosition @@ -462,7 +464,7 @@ public void LaunchProfile_ShortForm() /// [Theory] [InlineData(new[] { "--configuration", "release" }, new[] { "--property:Configuration=release", NugetInteractiveProperty })] - [InlineData(new[] { "--framework", "net9.0" }, new[] { "--property:TargetFramework=net9.0", NugetInteractiveProperty })] + [InlineData(new[] { "--framework", "net9.0" }, new[] { NugetInteractiveProperty }, new string[0])] [InlineData(new[] { "--runtime", "arm64" }, new[] { NugetInteractiveProperty, "--property:RuntimeIdentifier=arm64", "--property:_CommandLineDefinedRuntimeIdentifier=true" })] [InlineData(new[] { "--property", "b=1" }, new[] { "--property:b=1", NugetInteractiveProperty })] [InlineData(new[] { "--project", "x.csproj" }, new[] { NugetInteractiveProperty }, new[] { "--project", "x.csproj" })] diff --git a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs index 7fc6f9f504a7..3e3afd830978 100644 --- a/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/LaunchSettingsTests.cs @@ -84,12 +84,18 @@ public async Task RunsWithDotnetLaunchProfileEnvVariableWhenExplicitlySpecifiedB Assert.Equal("<<>>", await App.AssertOutputLineStartsWith("DOTNET_LAUNCH_PROFILE = ")); } - [Fact] - public async Task RunsWithIterationEnvVariable() + [Theory] + [CombinatorialData] + public async Task RunsWithIterationEnvVariable(bool hotReload) { var testAsset = TestAssets.CopyTestAsset(AppName) .WithSource(); + if (!hotReload) + { + App.WatchArgs.Add("--no-hot-reload"); + } + App.Start(testAsset, []); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForFileChangeBeforeRestarting); diff --git a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs index 477657ecc0c7..5d3458f3c70f 100644 --- a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs @@ -12,10 +12,13 @@ public class AspireHotReloadTests(ITestOutputHelper logger) : DotNetWatchTestBas [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/sdk/issues/53058, https://github.com/dotnet/sdk/issues/53061, https://github.com/dotnet/sdk/issues/53114 public async Task Aspire_BuildError_ManualRestart() { - var tfm = ToolsetInfo.CurrentTargetFramework; var testAsset = TestAssets.CopyTestAsset("WatchAspire") .WithSource(); + var serviceProjectDisplay = $"WatchAspire.ApiService ({ToolsetInfo.CurrentTargetFramework})"; + var webProjectDisplay = $"WatchAspire.Web ({ToolsetInfo.CurrentTargetFramework})"; + var hostProjectDisplay = $"WatchAspire.AppHost ({ToolsetInfo.CurrentTargetFramework})"; + var serviceSourcePath = Path.Combine(testAsset.Path, "WatchAspire.ApiService", "Program.cs"); var serviceProjectPath = Path.Combine(testAsset.Path, "WatchAspire.ApiService", "WatchAspire.ApiService.csproj"); var serviceSource = File.ReadAllText(serviceSourcePath, Encoding.UTF8); @@ -63,7 +66,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains(" โ” Do you want to restart these projects? Yes (y) / No (n) / Always (a) / Never (v)"); await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); - await App.WaitUntilOutputContains($"dotnet watch โŒ {serviceSourcePath}(40,1): error ENC0020: Renaming record 'WeatherForecast' requires restarting the application."); + await App.WaitUntilOutputContains($"dotnet watch โŒ [{serviceProjectDisplay}] {serviceSourcePath}(40,1): error ENC0020: Renaming record 'WeatherForecast' requires restarting the application."); await App.WaitUntilOutputContains("dotnet watch โŒš Affected projects:"); await App.WaitUntilOutputContains("dotnet watch โŒš WatchAspire.ApiService"); App.Process.ClearOutput(); @@ -74,7 +77,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains("Application is shutting down..."); - await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited"); + await App.WaitUntilOutputContains(MessageDescriptor.Exited, serviceProjectDisplay); await App.WaitUntilOutputContains(MessageDescriptor.Building); await App.WaitUntilOutputContains("error CS0246: The type or namespace name 'WeatherForecast' could not be found"); @@ -95,7 +98,7 @@ public async Task Aspire_BuildError_ManualRestart() // The agent startup hook might not be initialized yet (signal handlers registered), // so the process might need to be forcefully killed. We could wait until the agent is initialized // but it's good to test this scenario. - await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, $"WatchAspire.ApiService ({tfm})"); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, serviceProjectDisplay); App.Process.ClearOutput(); @@ -103,9 +106,10 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains(MessageDescriptor.ShutdownRequested); - await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited"); - await App.WaitUntilOutputContains($"[WatchAspire.Web ({tfm})] Exited"); - await App.WaitUntilOutputContains($"[WatchAspire.AppHost ({tfm})] Exited"); + // Not checking specific exited message since on shutdown we might see non-zero exit codes + await App.WaitUntilOutputContains($"[{serviceProjectDisplay}] Exited"); + await App.WaitUntilOutputContains($"[{webProjectDisplay}] Exited"); + await App.WaitUntilOutputContains($"[{hostProjectDisplay}] Exited"); await App.WaitUntilOutputContains("dotnet watch โญ Waiting for server to shutdown ..."); diff --git a/test/dotnet-watch.Tests/HotReload/AutoRestartTests.cs b/test/dotnet-watch.Tests/HotReload/AutoRestartTests.cs index ac8eb70ca1a9..7640f6f0f981 100644 --- a/test/dotnet-watch.Tests/HotReload/AutoRestartTests.cs +++ b/test/dotnet-watch.Tests/HotReload/AutoRestartTests.cs @@ -29,6 +29,7 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive) }); } + var projectDisplay = $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})"; var programPath = Path.Combine(testAsset.Path, "Program.cs"); App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []); @@ -42,9 +43,9 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive) await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); - await App.WaitUntilOutputContains($"โŒš [auto-restart] {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); + await App.WaitUntilOutputContains($"โŒš [{projectDisplay}] [auto-restart] {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); + await App.WaitUntilOutputContains(MessageDescriptor.Exited, projectDisplay); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, projectDisplay); App.Process.ClearOutput(); // valid edit: @@ -122,6 +123,7 @@ public async Task AutoRestartOnRudeEditAfterRestartPrompt() var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") .WithSource(); + var projectDisplay = $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})"; var programPath = Path.Combine(testAsset.Path, "Program.cs"); App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); @@ -133,18 +135,18 @@ public async Task AutoRestartOnRudeEditAfterRestartPrompt() UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}")); // the prompt is printed into stdout while the error is printed into stderr, so they might arrive in any order: - await App.WaitUntilOutputContains(" โ” Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)"); await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); + await App.WaitUntilOutputContains($"โŒ [{projectDisplay}] {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); + await App.WaitUntilOutputContains(" โ” Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)"); - await App.WaitUntilOutputContains($"โŒ {programPath}(39,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); App.Process.ClearOutput(); App.SendKey('a'); await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); - App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); - App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); + App.AssertOutputContains(MessageDescriptor.Exited, projectDisplay); + App.AssertOutputContains(MessageDescriptor.LaunchedProcess, projectDisplay); App.Process.ClearOutput(); // rude edit: deleting virtual method @@ -153,9 +155,9 @@ public async Task AutoRestartOnRudeEditAfterRestartPrompt() await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); - await App.WaitUntilOutputContains($"โŒš [auto-restart] {programPath}(39,1): error ENC0033: Deleting method 'F()' requires restarting the application."); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); + await App.WaitUntilOutputContains($"โŒš [{projectDisplay}] [auto-restart] {programPath}(39,1): error ENC0033: Deleting method 'F()' requires restarting the application."); + await App.WaitUntilOutputContains(MessageDescriptor.Exited, projectDisplay); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, projectDisplay); } [Theory] @@ -178,6 +180,7 @@ public async Task AutoRestartOnNoEffectEdit(bool nonInteractive) }); } + var projectDisplay = $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})"; var programPath = Path.Combine(testAsset.Path, "Program.cs"); App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []); @@ -191,9 +194,9 @@ public async Task AutoRestartOnNoEffectEdit(bool nonInteractive) await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); await App.WaitUntilOutputContains(MessageDescriptor.RestartNeededToApplyChanges); - await App.WaitUntilOutputContains($"โŒš [auto-restart] {programPath}(17,19): warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted."); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); - await App.WaitUntilOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); + await App.WaitUntilOutputContains($"โŒš [{projectDisplay}] [auto-restart] {programPath}(17,19): warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted."); + await App.WaitUntilOutputContains(MessageDescriptor.Exited, projectDisplay); + await App.WaitUntilOutputContains(MessageDescriptor.LaunchedProcess, projectDisplay); await App.WaitUntilOutputContains(""); App.Process.ClearOutput(); diff --git a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs index 9c72fad4c3f0..c44be941df00 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs @@ -1,76 +1,135 @@ ๏ปฟ// 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.Runtime.CompilerServices; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; -public class BuildProjectsTests : IDisposable +public class BuildProjects(ITestOutputHelper output) { - private readonly HotReloadDotNetWatcher _watcher; - private readonly List _buildFiles = []; - private string? _solutionFile; - - public BuildProjectsTests(ITestOutputHelper output) + private class TestContext : IDisposable { - var environmentOptions = TestOptions.GetEnvironmentOptions(); - var processOutputReporter = new TestProcessOutputReporter(); + public readonly HotReloadDotNetWatcher Watcher; + public readonly FileWatcher FileWatcher; + public readonly TestConsole Console; + + public readonly List BuildInvocations = []; + public string? SolutionFile; - var runner = new TestProcessRunner() + public TestContext(ITestOutputHelper output, ImmutableArray rootProjects) { - RunImpl = (processSpec, _, _) => - { - Assert.Equal("build", processSpec.Arguments[0]); - Assert.Equal("arg1", processSpec.Arguments[2]); - Assert.Equal("arg2", processSpec.Arguments[3]); + var environmentOptions = TestOptions.GetEnvironmentOptions(); + var processOutputReporter = new TestProcessOutputReporter(); - var target = processSpec.Arguments[1]; - if (Path.GetExtension(target) == ".slnx") + var processRunner = new TestProcessRunner() + { + RunImpl = (processSpec, _, _) => { - _solutionFile = target; - target = ""; + LogBuildInvocation(processSpec); + return 0; } + }; + + var context = new DotNetWatchContext() + { + ProcessOutputReporter = processOutputReporter, + LoggerFactory = NullLoggerFactory.Instance, + Logger = NullLogger.Instance, + BuildLogger = NullLogger.Instance, + ProcessRunner = processRunner, + Options = new(), + MainProjectOptions = null, + RootProjects = rootProjects, + BuildArguments = ["-p", "A=1"], + EnvironmentOptions = environmentOptions, + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), + BrowserRefreshServerFactory = new BrowserRefreshServerFactory() + }; + + FileWatcher = new FileWatcher(NullLogger.Instance, environmentOptions); + + Console = new TestConsole(output); + Watcher = new HotReloadDotNetWatcher(context, Console, runtimeProcessLauncherFactory: null); + } + + public void LogBuildInvocation(ProcessSpec processSpec) + { + SolutionFile = processSpec.Arguments.FirstOrDefault(a => a.EndsWith(".slnx")); - _buildFiles.Add(target); - return 0; - } - }; + // Replace path to solution, which is a temp path, with placeholder to make assertions easier. + BuildInvocations.Add(string.Join(" ", processSpec.Arguments.Select(a => a == SolutionFile ? "" : a))); + } - var context = new DotNetWatchContext() + public void Dispose() { - ProcessOutputReporter = processOutputReporter, - LoggerFactory = NullLoggerFactory.Instance, - Logger = NullLogger.Instance, - BuildLogger = NullLogger.Instance, - ProcessRunner = runner, - Options = new(), - MainProjectOptions = null, - RootProjects = [], - TargetFramework = null, - BuildArguments = ["arg1", "arg2"], - EnvironmentOptions = environmentOptions, - BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), - BrowserRefreshServerFactory = new BrowserRefreshServerFactory() - }; - - var console = new TestConsole(output); - _watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); + Assert.False(File.Exists(SolutionFile)); + } } - public void Dispose() + private TestContext CreateContext(string[]? rootProjects = null) + => new(output, rootProjects?.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath).ToImmutableArray() ?? []); + + [Fact] + public async Task SingleProject_NotMain() { - Assert.False(File.Exists(_solutionFile)); + var dir = Path.GetTempPath(); + var project1 = Path.Combine(dir, "Project1.csproj"); + + using var context = CreateContext(); + + var result = await context.Watcher.BuildProjectsAsync( + [new ProjectRepresentation(project1, entryPointFilePath: null)], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: (_, _) => + { + Assert.Fail("Selector should not be invoked"); + return ValueTask.FromResult("n/a"); + }, + CancellationToken.None); + + Assert.True(result.Success); + + AssertEx.SequenceEqual([$"build {project1} -p A=1"], context.BuildInvocations); } [Fact] - public async Task SingleProject() + public async Task SingleProject_Main() { var dir = Path.GetTempPath(); var project1 = Path.Combine(dir, "Project1.csproj"); - Assert.True(await _watcher.BuildProjectsAsync([new ProjectRepresentation(project1, entryPointFilePath: null)], CancellationToken.None)); + File.WriteAllText(project1, $""" + + + Exe + net9.0 + + + """); - AssertEx.SequenceEqual([project1], _buildFiles); + using var context = CreateContext([project1]); + + var result = await context.Watcher.BuildProjectsAsync( + [new ProjectRepresentation(project1, entryPointFilePath: null)], + context.FileWatcher, + mainProjectOptions: TestOptions.ProjectOptions, + frameworkSelector: (_, _) => + { + Assert.Fail("Selector should not be invoked"); + return ValueTask.FromResult("n/a"); + }, + CancellationToken.None); + + Assert.True(result.Success); + + AssertEx.SequenceEqual( + [ + $"restore {project1} -p A=1 -consoleLoggerParameters:NoSummary", + $"build {project1} -p A=1 --framework net9.0 --no-restore" + ], context.BuildInvocations); } [Fact] @@ -80,24 +139,47 @@ public async Task MultipleProjects() var project1 = Path.Combine(dir, "Project1.csproj"); var project2 = Path.Combine(dir, "Project2.csproj"); - Assert.True(await _watcher.BuildProjectsAsync( - [ - new ProjectRepresentation(project1, entryPointFilePath: null), - new ProjectRepresentation(project2, entryPointFilePath: null) - ], CancellationToken.None)); + using var context = CreateContext(); + + var result = await context.Watcher.BuildProjectsAsync( + projects: + [ + new ProjectRepresentation(project1, entryPointFilePath: null), + new ProjectRepresentation(project2, entryPointFilePath: null) + ], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: null, + CancellationToken.None); - AssertEx.SequenceEqual([""], _buildFiles); + Assert.True(result.Success); + + AssertEx.SequenceEqual(["build -p A=1"], context.BuildInvocations); } - [Fact] - public async Task SingleFile() + [Theory] + [CombinatorialData] + public async Task SingleFile(bool isMain) { var dir = Path.GetTempPath(); var file1 = Path.Combine(dir, "File1.cs"); - Assert.True(await _watcher.BuildProjectsAsync([new ProjectRepresentation(projectPath: null, entryPointFilePath: file1)], CancellationToken.None)); + using var context = CreateContext([file1]); - AssertEx.SequenceEqual([file1], _buildFiles); + var result = await context.Watcher.BuildProjectsAsync( + [new ProjectRepresentation(projectPath: null, entryPointFilePath: file1)], + context.FileWatcher, + mainProjectOptions: isMain ? TestOptions.GetProjectOptions(["--file", file1]) : null, + frameworkSelector: (_, _) => + { + Assert.Fail("Selector should not be invoked"); + return ValueTask.FromResult("n/a"); + }, + CancellationToken.None); + + Assert.True(result.Success); + + AssertEx.SequenceEqual([$"build {file1} -p A=1"], context.BuildInvocations); } [Fact] @@ -107,17 +189,26 @@ public async Task MultipleFiles() var file1 = Path.Combine(dir, "File1.cs"); var file2 = Path.Combine(dir, "File2.cs"); - Assert.True(await _watcher.BuildProjectsAsync( - [ - new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), - new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) - ], CancellationToken.None)); + using var context = CreateContext(); + + var result = await context.Watcher.BuildProjectsAsync( + projects: + [ + new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), + new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) + ], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: null, + CancellationToken.None); + + Assert.True(result.Success); AssertEx.SequenceEqual( [ - file1, - file2 - ], _buildFiles); + $"build {file1} -p A=1", + $"build {file2} -p A=1" + ], context.BuildInvocations); } [Fact] @@ -128,19 +219,28 @@ public async Task SingleProject_MultipleFiles() var file1 = Path.Combine(dir, "File1.cs"); var file2 = Path.Combine(dir, "File2.cs"); - Assert.True(await _watcher.BuildProjectsAsync( - [ - new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), - new ProjectRepresentation(project1, entryPointFilePath: null), - new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) - ], CancellationToken.None)); + using var context = CreateContext(); + + var result = await context.Watcher.BuildProjectsAsync( + projects: + [ + new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), + new ProjectRepresentation(project1, entryPointFilePath: null), + new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) + ], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: null, + CancellationToken.None); + + Assert.True(result.Success); AssertEx.SequenceEqual( [ - project1, - file1, - file2 - ], _buildFiles); + $"build {project1} -p A=1", + $"build {file1} -p A=1", + $"build {file2} -p A=1" + ], context.BuildInvocations); } [Fact] @@ -152,19 +252,151 @@ public async Task MultipleProjects_MultipleFiles() var file1 = Path.Combine(dir, "File1.cs"); var file2 = Path.Combine(dir, "File2.cs"); - Assert.True(await _watcher.BuildProjectsAsync( + using var context = CreateContext(); + + var result = await context.Watcher.BuildProjectsAsync( + projects: + [ + new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), + new ProjectRepresentation(project1, entryPointFilePath: null), + new ProjectRepresentation(project2, entryPointFilePath: null), + new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) + ], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: null, + CancellationToken.None); + + Assert.True(result.Success); + + AssertEx.SequenceEqual( + [ + "build -p A=1", + $"build {file1} -p A=1", + $"build {file2} -p A=1" + ], context.BuildInvocations); + } + + [Theory] + [InlineData(ToolsetInfo.CurrentTargetFramework)] + [InlineData("net9.0")] + public async Task MultiTfm_FrameworkSelection(string expectedTfm) + { + var dir = TestAssetsManager.CreateTestDirectory(identifiers: [expectedTfm]); + var project1 = Path.Combine(dir.Path, "Project1.csproj"); + + var currentTfm = ToolsetInfo.CurrentTargetFramework; + + File.WriteAllText(project1, $""" + + + Exe + {currentTfm};net9.0 + + + """); + + using var context = CreateContext(rootProjects: [project1]); + + var result = await context.Watcher.BuildProjectsAsync( + [ProjectRepresentation.FromProjectOrEntryPointFilePath(project1)], + context.FileWatcher, + mainProjectOptions: TestOptions.ProjectOptions, + frameworkSelector: (frameworks, _) => + { + AssertEx.SequenceEqual([currentTfm, "net9.0"], frameworks); + return ValueTask.FromResult(expectedTfm); + }, + CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(result.ProjectGraph); + Assert.Equal(expectedTfm, result.MainProjectTargetFramework); + + AssertEx.SequenceEqual( + [ + $"restore {project1} -p A=1 -consoleLoggerParameters:NoSummary", + $"build {project1} -p A=1 --framework {expectedTfm} --no-restore" + ], context.BuildInvocations); + } + + [Fact] + public async Task MultiTfm_CommandLineOption() + { + var dir = TestAssetsManager.CreateTestDirectory(); + var project1 = Path.Combine(dir.Path, "Project1.csproj"); + + var currentTfm = ToolsetInfo.CurrentTargetFramework; + + File.WriteAllText(project1, $""" + + + Exe + {currentTfm};net9.0 + + + """); + + using var context = CreateContext(rootProjects: [project1]); + + var result = await context.Watcher.BuildProjectsAsync( + [ProjectRepresentation.FromProjectOrEntryPointFilePath(project1)], + context.FileWatcher, + mainProjectOptions: TestOptions.GetProjectOptions(["-f", "net9.0"]), + frameworkSelector: (frameworks, _) => + { + Assert.Fail("Selector should not be invoked"); + return ValueTask.FromResult("n/a"); + }, + CancellationToken.None); + + Assert.True(result.Success); + Assert.Null(result.ProjectGraph); + Assert.Equal("net9.0", result.MainProjectTargetFramework); + + AssertEx.SequenceEqual( [ - new ProjectRepresentation(projectPath: null, entryPointFilePath: file1), - new ProjectRepresentation(project1, entryPointFilePath: null), - new ProjectRepresentation(project2, entryPointFilePath: null), - new ProjectRepresentation(projectPath: null, entryPointFilePath: file2) - ], CancellationToken.None)); + $"build {project1} -p A=1 --framework net9.0" + ], context.BuildInvocations); + } + + [Fact] + public async Task MultiTfm_NoMainProject() + { + var dir = TestAssetsManager.CreateTestDirectory(); + var project1 = Path.Combine(dir.Path, "Project1.csproj"); + + var currentTfm = ToolsetInfo.CurrentTargetFramework; + + File.WriteAllText(project1, $""" + + + Exe + {currentTfm};net9.0 + + + """); + + using var context = CreateContext(rootProjects: [project1]); + + var result = await context.Watcher.BuildProjectsAsync( + [ProjectRepresentation.FromProjectOrEntryPointFilePath(project1)], + context.FileWatcher, + mainProjectOptions: null, + frameworkSelector: (frameworks, _) => + { + Assert.Fail("Selector should not be invoked"); + return ValueTask.FromResult("n/a"); + }, + CancellationToken.None); + + Assert.True(result.Success); + Assert.Null(result.ProjectGraph); + Assert.Null(result.MainProjectTargetFramework); AssertEx.SequenceEqual( [ - "", - file1, - file2 - ], _buildFiles); + $"build {project1} -p A=1" + ], context.BuildInvocations); } } diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 59788507d466..3f50d195e0b4 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -22,7 +22,7 @@ public async Task ReferenceOutputAssembly_False() var projectOptions = TestOptions.GetProjectOptions(cmdOptions); var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory); - var factory = new ProjectGraphFactory([hostProjectRepr], targetFramework: null, buildProperties: [], NullLogger.Instance); + var factory = new ProjectGraphFactory([hostProjectRepr], virtualProjectTargetFramework: null, buildProperties: [], NullLogger.Instance); var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false, CancellationToken.None); Assert.NotNull(projectGraph); @@ -39,7 +39,6 @@ public async Task ReferenceOutputAssembly_False() MainProjectOptions = TestOptions.ProjectOptions, RootProjects = [hostProjectRepr], BuildArguments = [], - TargetFramework = null, EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), BrowserRefreshServerFactory = new BrowserRefreshServerFactory() diff --git a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs index 7d81311f00de..2ddab8328703 100644 --- a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs @@ -13,10 +13,11 @@ public class MauiHotReloadTests(ITestOutputHelper logger) : DotNetWatchTestBase( /// Currently only works on Windows. /// Add TestPlatforms.OSX once https://github.com/dotnet/sdk/issues/45521 is fixed. /// - [PlatformSpecificFact(TestPlatforms.Windows)] - public async Task MauiBlazor() + [PlatformSpecificTheory(TestPlatforms.Windows)] + [CombinatorialData] + public async Task MauiBlazor(bool selectTfm) { - var testAsset = TestAssets.CopyTestAsset("WatchMauiBlazor") + var testAsset = TestAssets.CopyTestAsset("WatchMauiBlazor", identifier: selectTfm.ToString()) .WithSource(); var workloadInstallCommandSpec = new DotnetCommand(Logger, ["workload", "install", "maui", "--include-previews"]) @@ -29,10 +30,20 @@ public async Task MauiBlazor() var platform = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows10.0.19041.0" : "maccatalyst"; var tfm = $"{ToolsetInfo.CurrentTargetFramework}-{platform}"; - App.Start(testAsset, ["-f", tfm]); + App.Start(testAsset, selectTfm ? [] : ["-f", tfm], testFlags: TestFlags.ReadKeyFromStdin); + + if (selectTfm) + { + await App.WaitUntilOutputContains("โ” Select target framework"); + App.SendKey(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '2' : '1'); + } await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); + // only the selected target framework is built: + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), File.Exists(Path.Combine(testAsset.Path, "bin", "Debug", "net10.0-windows10.0.19041.0", "win-x64", "maui-blazor.dll"))); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.OSX), File.Exists(Path.Combine(testAsset.Path, "bin", "Debug", "net10.0-maccatalyst", "maccatalyst-x64", "maui-blazor.dll"))); + // update code file: var razorPath = Path.Combine(testAsset.Path, "Components", "Pages", "Home.razor"); UpdateSourceFile(razorPath, content => content.Replace("Hello, world!", "Updated")); @@ -58,6 +69,11 @@ public async Task MauiBlazor() await App.WaitUntilOutputContains(MessageDescriptor.StaticAssetsChangesApplied); await App.WaitUntilOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); await App.WaitUntilOutputContains(MessageDescriptor.NoManagedCodeChangesToApply); + + // no warnings - these would be reported if we tried to access web asset manifest from unbuilt TFMs: + App.AssertOutputDoesNotContain(MessageDescriptor.StaticWebAssetManifestNotFound); + App.AssertOutputDoesNotContain(MessageDescriptor.ScopedCssBundleFileNotFound); + App.AssertOutputDoesNotContain(MessageDescriptor.ManifestFileNotFound); } } } diff --git a/test/dotnet-watch.Tests/HotReload/ProjectUpdateTests.cs b/test/dotnet-watch.Tests/HotReload/ProjectUpdateTests.cs index e3e5b32274aa..6dec3d3a8291 100644 --- a/test/dotnet-watch.Tests/HotReload/ProjectUpdateTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ProjectUpdateTests.cs @@ -55,13 +55,14 @@ public async Task Update(bool isDirectoryProps) var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps", identifier: isDirectoryProps.ToString()) .WithSource(); + var dependencyProjectDisplay = $"Dependency ({ToolsetInfo.CurrentTargetFramework})"; var symbolName = isDirectoryProps ? "BUILD_CONST_IN_PROPS" : "BUILD_CONST_IN_CSPROJ"; var dependencyDir = Path.Combine(testAsset.Path, "Dependency"); - var libSourcePath = Path.Combine(dependencyDir, "Foo.cs"); + var dependencySourcePath = Path.Combine(dependencyDir, "Foo.cs"); var buildFilePath = isDirectoryProps ? Path.Combine(testAsset.Path, "Directory.Build.props") : Path.Combine(dependencyDir, "Dependency.csproj"); - File.WriteAllText(libSourcePath, $$""" + File.WriteAllText(dependencySourcePath, $$""" public class Lib { public static void Print() @@ -85,7 +86,7 @@ public static void Print() await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); await App.WaitUntilOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); - await App.WaitUntilOutputContains("dotnet watch โŒš [auto-restart] error ENC1102: Changing project setting 'DefineConstants'"); + await App.WaitUntilOutputContains($"dotnet watch โŒš [{dependencyProjectDisplay}] [auto-restart] error ENC1102: Changing project setting 'DefineConstants'"); await App.WaitUntilOutputContains($"{symbolName} not set"); } diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 59ca84b94159..99dcb0dc45da 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -7,8 +7,7 @@ public class RuntimeProcessLauncherTests(ITestOutputHelper logger) : DotNetWatch { public enum TriggerEvent { - HotReloadSessionStarting, - HotReloadSessionStarted, + RuntimeProcessLauncherCreated, WaitingForChanges, } @@ -40,8 +39,7 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) w.Observer.RegisterAction(trigger switch { - TriggerEvent.HotReloadSessionStarting => MessageDescriptor.HotReloadSessionStartingNotification, - TriggerEvent.HotReloadSessionStarted => MessageDescriptor.HotReloadSessionStarted, + TriggerEvent.RuntimeProcessLauncherCreated => MessageDescriptor.RuntimeProcessLauncherCreatedNotification, TriggerEvent.WaitingForChanges => MessageDescriptor.WaitingForChanges, _ => throw new InvalidOperationException(), }, () => @@ -52,7 +50,7 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) return; } - // service should have been created before Hot Reload session started: + // service should have been created after MessageDescriptor.RuntimeProcessLauncherCreatedNotification has been received: Assert.NotNull(w.Service); w.Service.Launch(serviceProjectA, workingDirectory, w.ShutdownSource.Token).Wait(); diff --git a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs index a414559d9c12..7a4a018e9fa1 100644 --- a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs +++ b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.HotReloadNotSupported.cs @@ -13,7 +13,7 @@ public class SourceFileUpdateTests_HotReloadNotSupported(ITestOutputHelper logge [InlineData("StartupHookSupport", "False")] public async Task ChangeFileInAotProject(string propertyName, string propertyValue) { - var tfm = ToolsetInfo.CurrentTargetFramework; + var projectDisplay = $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})"; var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: $"{propertyName};{propertyValue}") .WithSource() @@ -28,13 +28,14 @@ public async Task ChangeFileInAotProject(string propertyName, string propertyVal App.Start(testAsset, ["--non-interactive"]); - await App.WaitForOutputLineContaining($"[WatchHotReloadApp ({tfm})] " + MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'")); + var message = MessageDescriptor.ProjectDoesNotSupportHotReload.GetMessage($"'{propertyName}' property is '{propertyValue}'"); + await App.WaitForOutputLineContaining($"[{projectDisplay}] {message}"); await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); App.Process.ClearOutput(); UpdateSourceFile(programPath, content => content.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"\");")); - await App.WaitForOutputLineContaining($"[auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. + await App.WaitForOutputLineContaining($"[{projectDisplay}] [auto-restart] {programPath}(1,1): error ENC0097"); // Applying source changes while the application is running is not supported by the runtime. await App.WaitForOutputLineContaining(""); } diff --git a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs index 4c53e7478d63..d6f9637e291f 100644 --- a/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs +++ b/test/dotnet-watch.Tests/HotReload/SourceFileUpdateTests.cs @@ -88,7 +88,7 @@ public async Task BaselineCompilationError() App.Start(testAsset, []); - await App.WaitUntilOutputContains(MessageDescriptor.WaitingForFileChangeBeforeRestarting); + await App.WaitUntilOutputContains(MessageDescriptor.FixBuildError); UpdateSourceFile(programPath, """ System.Console.WriteLine(""); diff --git a/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs b/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs new file mode 100644 index 000000000000..ac59577f9b5f --- /dev/null +++ b/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs @@ -0,0 +1,37 @@ +๏ปฟ// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class TargetFrameworkSelectionPromptTests(ITestOutputHelper output) +{ + [Theory] + [CombinatorialData] + public async Task PreviousSelection([CombinatorialRange(0, count: 3)] int index) + { + var console = new TestConsole(output); + var consoleInput = new ConsoleInputReader(console, LogLevel.Debug, suppressEmojis: false); + var prompt = new TargetFrameworkSelectionPrompt(consoleInput); + + var frameworks = new[] { "net7.0", "net8.0", "net9.0" }; + var expectedTfm = frameworks[index]; + + // first selection: + console.QueuedKeyPresses.Add(new ConsoleKeyInfo((char)('1' + index), ConsoleKey.D1 + index, shift: false, alt: false, control: false)); + + Assert.Equal(expectedTfm, await prompt.SelectAsync(frameworks, CancellationToken.None)); + Assert.Equal(expectedTfm, prompt.PreviousSelection); + console.QueuedKeyPresses.Clear(); + + // should use previous selection: + Assert.Equal(expectedTfm, await prompt.SelectAsync(["NET9.0", "net7.0", "net8.0"], CancellationToken.None)); + + // second selection: + console.QueuedKeyPresses.Add(new ConsoleKeyInfo('3', ConsoleKey.D3, shift: false, alt: false, control: false)); + + // should prompt again: + Assert.Equal("net10.0", await prompt.SelectAsync(["net9.0", "net7.0", "net10.0"], CancellationToken.None)); + } +} diff --git a/test/dotnet-watch.Tests/TestUtilities/TestAssetsManagerExtensions.cs b/test/dotnet-watch.Tests/TestUtilities/TestAssetsManagerExtensions.cs new file mode 100644 index 000000000000..1843bb800419 --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/TestAssetsManagerExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟ// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.Watch.UnitTests; + +internal static class TestAssetsManagerExtensions +{ + extension(TestAssetsManager) + { + public static TestDirectory CreateTestDirectory([CallerMemberName] string? testName = null, object[]? identifiers = null) + => TestDirectory.Create(TestAssetsManager.GetTestDestinationDirectoryPath( + testName, + testName, + identifiers != null ? string.Join(';', identifiers.Select(id => id != null ? "_" + id.ToString() : "null")) : string.Empty, + baseDirectory: null)); + } +} diff --git a/test/dotnet-watch.Tests/TestUtilities/TestConsole.cs b/test/dotnet-watch.Tests/TestUtilities/TestConsole.cs index 225988686d89..80d02e83ae4c 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestConsole.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestConsole.cs @@ -1,13 +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 System.Reflection; - namespace Microsoft.DotNet.Watch.UnitTests { internal class TestConsole : IConsole { - public event Action? KeyPressed; + private Action? _keyPressed; private readonly TestOutputWriter _testWriter; @@ -19,6 +17,8 @@ internal class TestConsole : IConsole public bool IsErrorRedirected { get; } = false; public ConsoleColor ForegroundColor { get; set; } + public readonly List QueuedKeyPresses = []; + public TestConsole(ITestOutputHelper output) { _testWriter = new TestOutputWriter(output); @@ -26,12 +26,29 @@ public TestConsole(ITestOutputHelper output) Out = _testWriter; } + public event Action KeyPressed + { + add + { + _keyPressed += value; + foreach (var key in QueuedKeyPresses) + { + value.Invoke(key); + } + QueuedKeyPresses.Clear(); + } + remove + { + _keyPressed -= value; + } + } + public void Clear() { } public void PressKey(ConsoleKeyInfo key) { - Assert.NotNull(KeyPressed); - KeyPressed.Invoke(key); + Assert.NotNull(_keyPressed); + _keyPressed.Invoke(key); } public void ResetColor() diff --git a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs index 1605f3f58ac4..3744a59299b2 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs @@ -27,6 +27,11 @@ public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = public static CommandLineOptions GetCommandLineOptions(string[] args) => CommandLineOptions.Parse(args, NullLogger.Instance, TextWriter.Null, out _) ?? throw new InvalidOperationException(); + public static ProjectOptions GetProjectOptions(string[] args) + => GetProjectOptions(GetCommandLineOptions(args)); + public static ProjectOptions GetProjectOptions(CommandLineOptions options) - => options.GetMainProjectOptions(new ProjectRepresentation(options.ProjectPath ?? "test.csproj", entryPointFilePath: null), workingDirectory: ""); + => options.GetMainProjectOptions( + new ProjectRepresentation(options.ProjectPath == null && options.FilePath == null ? "test.csproj" : options.ProjectPath, options.FilePath), + workingDirectory: ""); } diff --git a/test/dotnet-watch.Tests/TestUtilities/TestProcessRunner.cs b/test/dotnet-watch.Tests/TestUtilities/TestProcessRunner.cs index 5bd23a49bb62..7acd5ccb7027 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestProcessRunner.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestProcessRunner.cs @@ -8,8 +8,11 @@ namespace Microsoft.DotNet.Watch.UnitTests; internal class TestProcessRunner() : ProcessRunner(processCleanupTimeout: TimeSpan.MaxValue) { - public Func? RunImpl; + public Func? RunImpl; - public override Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) - => Task.FromResult(RunImpl?.Invoke(processSpec, logger, launchResult) ?? throw new NotImplementedException()); + public async override Task RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) + { + var result = RunImpl?.Invoke(processSpec, logger, launchResult); + return result ?? await base.RunAsync(processSpec, logger, launchResult, processTerminationToken); + } } diff --git a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs index 5fe0ea928710..b4dd4d7a9ae1 100644 --- a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs +++ b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs @@ -28,7 +28,6 @@ private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementali Options = new(), MainProjectOptions = TestOptions.ProjectOptions, RootProjects = [TestOptions.ProjectOptions.Representation], - TargetFramework = null, BuildArguments = [], EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), diff --git a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs index a74be1cd7c0a..843487a91251 100644 --- a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs +++ b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs @@ -27,7 +27,6 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen Options = new(), MainProjectOptions = projectOptions, RootProjects = [projectOptions.Representation], - TargetFramework = cmdOptions.TargetFramework, BuildArguments = cmdOptions.BuildArguments, EnvironmentOptions = environmentOptions, BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), @@ -113,8 +112,8 @@ public void AddsNoRestoreSwitch_WithAdditionalArguments() var context = CreateContext(["run", "-f", ToolsetInfo.CurrentTargetFramework]); var evaluator = new BuildEvaluator(context); - AssertEx.SequenceEqual(["run", "-f", ToolsetInfo.CurrentTargetFramework], evaluator.GetProcessArguments(iteration: 0)); - AssertEx.SequenceEqual(["run", "--no-restore", "-f", ToolsetInfo.CurrentTargetFramework], evaluator.GetProcessArguments(iteration: 1)); + AssertEx.SequenceEqual(["run", "--framework", ToolsetInfo.CurrentTargetFramework], evaluator.GetProcessArguments(iteration: 0)); + AssertEx.SequenceEqual(["run", "--no-restore", "--framework", ToolsetInfo.CurrentTargetFramework], evaluator.GetProcessArguments(iteration: 1)); } [Fact]