diff --git a/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj index af178129da01..491d57555c28 100644 --- a/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj +++ b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs new file mode 100644 index 000000000000..a49547f3948d --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal static class PropertyNames +{ + public const string TargetFramework = nameof(TargetFramework); + public const string TargetFrameworkIdentifier = nameof(TargetFrameworkIdentifier); + public const string TargetPath = nameof(TargetPath); + public const string EnableDefaultItems = nameof(EnableDefaultItems); + public const string TargetFrameworks = nameof(TargetFrameworks); + public const string WebAssemblyHotReloadCapabilities = nameof(WebAssemblyHotReloadCapabilities); + public const string TargetFrameworkVersion = nameof(TargetFrameworkVersion); + public const string TargetName = nameof(TargetName); + public const string IntermediateOutputPath = nameof(IntermediateOutputPath); + public const string HotReloadAutoRestart = nameof(HotReloadAutoRestart); + public const string DefaultItemExcludes = nameof(DefaultItemExcludes); + public const string CustomCollectWatchItems = nameof(CustomCollectWatchItems); + public const string UsingMicrosoftNETSdkRazor = nameof(UsingMicrosoftNETSdkRazor); + public const string DotNetWatchContentFiles = nameof(DotNetWatchContentFiles); + public const string DotNetWatchBuild = nameof(DotNetWatchBuild); + public const string DesignTimeBuild = nameof(DesignTimeBuild); + public const string SkipCompilerExecution = nameof(SkipCompilerExecution); + public const string ProvideCommandLineArgs = nameof(ProvideCommandLineArgs); +} + +internal static class ItemNames +{ + public const string Watch = nameof(Watch); + public const string AdditionalFiles = nameof(AdditionalFiles); + public const string Compile = nameof(Compile); + public const string Content = nameof(Content); + public const string ProjectCapability = nameof(ProjectCapability); +} + +internal static class MetadataNames +{ + public const string Watch = nameof(Watch); +} + +internal static class TargetNames +{ + public const string Compile = nameof(Compile); + public const string Restore = nameof(Restore); + public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); +} diff --git a/src/BuiltInTools/dotnet-watch/Build/BuildReporter.cs b/src/BuiltInTools/dotnet-watch/Build/BuildReporter.cs new file mode 100644 index 000000000000..5a06112ab5a1 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/BuildReporter.cs @@ -0,0 +1,104 @@ +// 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; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class BuildReporter(IReporter reporter, EnvironmentOptions environmentOptions) +{ + public IReporter Reporter => reporter; + public EnvironmentOptions EnvironmentOptions => environmentOptions; + + public Loggers GetLoggers(string projectPath, string operationName) + => new(reporter, environmentOptions.GetTestBinLogPath(projectPath, operationName)); + + public void ReportWatchedFiles(Dictionary fileItems) + { + reporter.Verbose($"Watching {fileItems.Count} file(s) for changes"); + + if (environmentOptions.TestFlags.HasFlag(TestFlags.RunningAsTest)) + { + foreach (var file in fileItems.Values) + { + reporter.Verbose(file.StaticWebAssetPath != null + ? $"> {file.FilePath}{Path.PathSeparator}{file.StaticWebAssetPath}" + : $"> {file.FilePath}"); + } + } + } + + public sealed class Loggers(IReporter reporter, string? binLogPath) : IEnumerable, IDisposable + { + private readonly BinaryLogger? _binaryLogger = binLogPath != null + ? new() + { + Verbosity = LoggerVerbosity.Diagnostic, + Parameters = "LogFile=" + binLogPath, + } + : null; + + private readonly OutputLogger _outputLogger = + new(reporter) + { + Verbosity = LoggerVerbosity.Minimal + }; + + public void Dispose() + { + _outputLogger.Clear(); + } + + public IEnumerator GetEnumerator() + { + yield return _outputLogger; + + if (_binaryLogger != null) + { + yield return _binaryLogger; + } + } + + public void ReportOutput() + { + if (binLogPath != null) + { + reporter.Verbose($"Binary log: '{binLogPath}'"); + } + + _outputLogger.ReportOutput(); + } + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + } + + private sealed class OutputLogger : ConsoleLogger + { + private readonly IReporter _reporter; + private readonly List _messages = []; + + public OutputLogger(IReporter reporter) + { + WriteHandler = Write; + _reporter = reporter; + } + + public IReadOnlyList Messages + => _messages; + + public void Clear() + => _messages.Clear(); + + private void Write(string message) + => _messages.Add(new OutputLine(message.TrimEnd('\r', '\n'), IsError: false)); + + public void ReportOutput() + { + _reporter.Output($"MSBuild output:"); + BuildOutput.ReportBuildOutput(_reporter, Messages, success: false, projectDisplay: null); + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs index 8204bc050199..ee209e05b03a 100644 --- a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs @@ -1,14 +1,15 @@ // 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 Microsoft.Build.Graph; namespace Microsoft.DotNet.Watch; -internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) +internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph projectGraph) { public readonly IReadOnlyDictionary Files = files; - public readonly ProjectGraph? ProjectGraph = projectGraph; + public readonly ProjectGraph ProjectGraph = projectGraph; public readonly FilePathExclusions ItemExclusions = projectGraph != null ? FilePathExclusions.Create(projectGraph) : FilePathExclusions.Empty; @@ -16,7 +17,7 @@ public readonly FilePathExclusions ItemExclusions private readonly Lazy> _lazyBuildFiles = new(() => projectGraph != null ? CreateBuildFileSet(projectGraph) : new HashSet()); - public static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) + private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph) => projectGraph.ProjectNodes.SelectMany(p => p.ProjectInstance.ImportPaths) .Concat(projectGraph.ProjectNodes.Select(p => p.ProjectInstance.FullPath)) .ToHashSet(PathUtilities.OSSpecificPathComparer); @@ -29,4 +30,138 @@ public void WatchFiles(FileWatcher fileWatcher) fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); fileWatcher.WatchFiles(BuildFiles); } + + /// + /// Loads project graph and performs design-time build. + /// + public static EvaluationResult? TryCreate( + string rootProjectPath, + IEnumerable buildArguments, + IReporter reporter, + EnvironmentOptions environmentOptions, + bool restore, + CancellationToken cancellationToken) + { + var buildReporter = new BuildReporter(reporter, environmentOptions); + + // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md + + var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) + .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) + .SetItem(PropertyNames.DotNetWatchBuild, "true") + .SetItem(PropertyNames.DesignTimeBuild, "true") + .SetItem(PropertyNames.SkipCompilerExecution, "true") + .SetItem(PropertyNames.ProvideCommandLineArgs, "true") + // F# targets depend on host path variable: + .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); + + var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph( + rootProjectPath, + globalOptions, + reporter, + projectGraphRequired: true, + cancellationToken); + + if (projectGraph == null) + { + return null; + } + + var rootNode = projectGraph.GraphRoots.Single(); + + if (restore) + { + using (var loggers = buildReporter.GetLoggers(rootNode.ProjectInstance.FullPath, "Restore")) + { + if (!rootNode.ProjectInstance.Build([TargetNames.Restore], loggers)) + { + reporter.Error($"Failed to restore project '{rootProjectPath}'."); + loggers.ReportOutput(); + return null; + } + } + } + + var fileItems = new Dictionary(); + + foreach (var project in projectGraph.ProjectNodesTopologicallySorted) + { + // Deep copy so that we can reuse the graph for building additional targets later on. + // If we didn't copy the instance the targets might duplicate items that were already + // populated by design-time build. + var projectInstance = project.ProjectInstance.DeepCopy(); + + // skip outer build project nodes: + if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") + { + continue; + } + + var customCollectWatchItems = projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems); + + using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) + { + if (!projectInstance.Build([TargetNames.Compile, .. customCollectWatchItems], loggers)) + { + reporter.Error($"Failed to build project '{projectInstance.FullPath}'."); + loggers.ReportOutput(); + return null; + } + } + + var projectPath = projectInstance.FullPath; + var projectDirectory = Path.GetDirectoryName(projectPath)!; + + // TODO: Compile and AdditionalItems should be provided by Roslyn + var items = projectInstance.GetItems(ItemNames.Compile) + .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) + .Concat(projectInstance.GetItems(ItemNames.Watch)); + + foreach (var item in items) + { + AddFile(item.EvaluatedInclude, staticWebAssetPath: null); + } + + if (!environmentOptions.SuppressHandlingStaticContentFiles && + projectInstance.GetBooleanPropertyValue(PropertyNames.UsingMicrosoftNETSdkRazor) && + projectInstance.GetBooleanPropertyValue(PropertyNames.DotNetWatchContentFiles, defaultValue: true)) + { + foreach (var item in projectInstance.GetItems(ItemNames.Content)) + { + if (item.GetBooleanMetadataValue(MetadataNames.Watch, defaultValue: true)) + { + var relativeUrl = item.EvaluatedInclude.Replace('\\', '/'); + if (relativeUrl.StartsWith("wwwroot/")) + { + AddFile(item.EvaluatedInclude, staticWebAssetPath: relativeUrl); + } + } + } + } + + void AddFile(string include, string? staticWebAssetPath) + { + var filePath = Path.GetFullPath(Path.Combine(projectDirectory, include)); + + if (!fileItems.TryGetValue(filePath, out var existingFile)) + { + fileItems.Add(filePath, new FileItem + { + FilePath = filePath, + ContainingProjectPaths = [projectPath], + StaticWebAssetPath = staticWebAssetPath, + }); + } + else if (!existingFile.ContainingProjectPaths.Contains(projectPath)) + { + // linked files might be included to multiple projects: + existingFile.ContainingProjectPaths.Add(projectPath); + } + } + } + + buildReporter.ReportWatchedFiles(fileItems); + + return new EvaluationResult(fileItems, projectGraph); + } } diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs new file mode 100644 index 000000000000..5ffe84d2604c --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -0,0 +1,163 @@ +// 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 Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.DotNet.Cli; + +namespace Microsoft.DotNet.Watch; + +internal static class ProjectGraphUtilities +{ + /// + /// Tries to create a project graph by running the build evaluation phase on the . + /// + public static ProjectGraph? TryLoadProjectGraph( + string rootProjectFile, + ImmutableDictionary globalOptions, + IReporter reporter, + bool projectGraphRequired, + CancellationToken cancellationToken) + { + var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); + try + { + // Create a new project collection that does not reuse element cache + // to work around https://github.com/dotnet/msbuild/issues/12064: + var collection = new ProjectCollection( + globalProperties: globalOptions, + loggers: [], + remoteLoggers: [], + ToolsetDefinitionLocations.Default, + maxNodeCount: 1, + onlyLogCriticalEvents: false, + loadProjectsReadOnly: false, + useAsynchronousLogging: false, + reuseProjectRootElementCache: false); + + return new ProjectGraph([entryPoint], collection, projectInstanceFactory: null, cancellationToken); + } + catch (Exception e) + { + reporter.Verbose("Failed to load project graph."); + + if (e is AggregateException { InnerExceptions: var innerExceptions }) + { + foreach (var inner in innerExceptions) + { + Report(inner); + } + } + else + { + Report(e); + } + + void Report(Exception e) + { + if (projectGraphRequired) + { + reporter.Error(e.Message); + } + else + { + reporter.Warn(e.Message); + } + } + } + + return null; + } + + public static string GetDisplayName(this ProjectGraphNode projectNode) + => $"{Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath)} ({projectNode.GetTargetFramework()})"; + + public static string GetTargetFramework(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFramework); + + public static IEnumerable GetTargetFrameworks(this ProjectGraphNode projectNode) + => projectNode.GetStringListPropertyValue(PropertyNames.TargetFrameworks); + + public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) + => EnvironmentVariableNames.TryParseTargetFrameworkVersion(projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFrameworkVersion)); + + public static IEnumerable GetWebAssemblyCapabilities(this ProjectGraphNode projectNode) + => projectNode.GetStringListPropertyValue(PropertyNames.WebAssemblyHotReloadCapabilities); + + public static bool IsTargetFrameworkVersionOrNewer(this ProjectGraphNode projectNode, Version minVersion) + => projectNode.GetTargetFrameworkVersion() is { } version && version >= minVersion; + + public static bool IsNetCoreApp(string identifier) + => string.Equals(identifier, ".NETCoreApp", StringComparison.OrdinalIgnoreCase); + + public static bool IsNetCoreApp(this ProjectGraphNode projectNode) + => IsNetCoreApp(projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFrameworkIdentifier)); + + public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVersion) + => projectNode.IsNetCoreApp() && projectNode.IsTargetFrameworkVersionOrNewer(minVersion); + + public static bool IsWebApp(this ProjectGraphNode projectNode) + => projectNode.GetCapabilities().Any(static value => value is "AspNetCore" or "WebAssembly"); + + public static string? GetOutputDirectory(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; + + public static string GetAssemblyName(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); + + public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null; + + public static IEnumerable GetCapabilities(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetItems(ItemNames.ProjectCapability).Select(item => item.EvaluatedInclude); + + public static bool IsAutoRestartEnabled(this ProjectGraphNode projectNode) + => projectNode.GetBooleanPropertyValue(PropertyNames.HotReloadAutoRestart); + + public static bool AreDefaultItemsEnabled(this ProjectGraphNode projectNode) + => projectNode.GetBooleanPropertyValue(PropertyNames.EnableDefaultItems); + + public static IEnumerable GetDefaultItemExcludes(this ProjectGraphNode projectNode) + => projectNode.GetStringListPropertyValue(PropertyNames.DefaultItemExcludes); + + public static IEnumerable GetStringListPropertyValue(this ProjectGraphNode projectNode, string propertyName) + => projectNode.ProjectInstance.GetStringListPropertyValue(propertyName); + + public static IEnumerable 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) + => GetBooleanPropertyValue(projectNode.ProjectInstance, propertyName, defaultValue); + + public static bool GetBooleanPropertyValue(this ProjectInstance project, string propertyName, bool defaultValue = false) + => project.GetPropertyValue(propertyName) is { Length: >0 } value ? bool.TryParse(value, out var result) && result : defaultValue; + + 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; + + public static IEnumerable GetTransitivelyReferencingProjects(this IEnumerable projects) + { + var visited = new HashSet(); + var queue = new Queue(); + foreach (var project in projects) + { + queue.Enqueue(project); + } + + while (queue.Count > 0) + { + var project = queue.Dequeue(); + if (visited.Add(project)) + { + foreach (var referencingProject in project.ReferencingProjects) + { + queue.Enqueue(referencingProject); + } + } + } + + return visited; + } +} diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs index d0f597d134f8..53dd2f99dbde 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs @@ -12,13 +12,5 @@ internal sealed class DotNetWatchContext public required ProcessRunner ProcessRunner { get; init; } public required ProjectOptions RootProjectOptions { get; init; } - - public MSBuildFileSetFactory CreateMSBuildFileSetFactory() - => new( - RootProjectOptions.ProjectPath, - RootProjectOptions.BuildArguments, - EnvironmentOptions, - ProcessRunner, - Reporter); } } diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs index 199c4d864fc2..846ddb54f30a 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs @@ -52,6 +52,8 @@ internal sealed record EnvironmentOptions( TestOutput: EnvironmentVariables.TestOutputDir ); + private static int s_uniqueLogId; + public bool RunningAsTest { get => (TestFlags & TestFlags.RunningAsTest) != TestFlags.None; } private static string GetMuxerPathFromEnvironment() @@ -61,5 +63,10 @@ private static string GetMuxerPathFromEnvironment() Debug.Assert(Path.GetFileNameWithoutExtension(muxerPath) == "dotnet", $"Invalid muxer path {muxerPath}"); return muxerPath; } + + public string? GetTestBinLogPath(string projectPath, string operationName) + => TestFlags.HasFlag(TestFlags.RunningAsTest) + ? Path.Combine(TestOutput, $"Watch.{operationName}.{Path.GetFileName(projectPath)}.{Interlocked.Increment(ref s_uniqueLogId)}.binlog") + : null; } } diff --git a/src/BuiltInTools/dotnet-watch/FileWatcher/DirectoryWatcher.cs b/src/BuiltInTools/dotnet-watch/FileWatcher/DirectoryWatcher.cs index 9891aa124193..1d7bb3137828 100644 --- a/src/BuiltInTools/dotnet-watch/FileWatcher/DirectoryWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/FileWatcher/DirectoryWatcher.cs @@ -42,9 +42,6 @@ protected void NotifyError(Exception e) OnError?.Invoke(this, e); } - public static DirectoryWatcher Create(string watchedDirectory, ImmutableHashSet watchedFileNames, bool includeSubdirectories) - => Create(watchedDirectory, watchedFileNames, EnvironmentVariables.IsPollingEnabled, includeSubdirectories); - public static DirectoryWatcher Create(string watchedDirectory, ImmutableHashSet watchedFileNames, bool usePollingWatcher, bool includeSubdirectories) { return usePollingWatcher ? diff --git a/src/BuiltInTools/dotnet-watch/FileWatcher/FileWatcher.cs b/src/BuiltInTools/dotnet-watch/FileWatcher/FileWatcher.cs index 24669468b88f..b4fa7c55ddc8 100644 --- a/src/BuiltInTools/dotnet-watch/FileWatcher/FileWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/FileWatcher/FileWatcher.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch { - internal class FileWatcher(IReporter reporter) : IDisposable + internal class FileWatcher(IReporter reporter, EnvironmentOptions environmentOptions) : IDisposable { // Directory watcher for each watched directory tree. // Keyed by full path to the root directory with a trailing directory separator. @@ -40,7 +40,7 @@ public void Dispose() protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, ImmutableHashSet fileNames, bool includeSubdirectories) { - var watcher = DirectoryWatcher.Create(directory, fileNames, includeSubdirectories); + var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories); if (watcher is EventBasedDirectoryWatcher eventBasedWatcher) { eventBasedWatcher.Logger = message => reporter.Verbose(message); @@ -204,9 +204,9 @@ void FileChangedCallback(ChangedPath change) return change; } - public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter reporter, Action? startedWatching, CancellationToken cancellationToken) + public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter reporter, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken) { - using var watcher = new FileWatcher(reporter); + using var watcher = new FileWatcher(reporter, environmentOptions); watcher.WatchContainingDirectories([filePath], includeSubdirectories: false); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs index d37e00c38548..57e48c5c7f45 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs @@ -30,7 +30,7 @@ public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) _ => "net10.0", }; - path = Path.Combine(AppContext.BaseDirectory, "hotreload", hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier.dll"); + path = Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier.dll"); return true; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index e958d41308d3..705a7abcb867 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -14,13 +14,12 @@ internal sealed class HotReloadDotNetWatcher private readonly RestartPrompt? _rudeEditRestartPrompt; private readonly DotNetWatchContext _context; - private readonly MSBuildFileSetFactory _fileSetFactory; + + internal Task? Test_FileChangesCompletedTask { get; set; } public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory) { _context = context; - _fileSetFactory = context.CreateMSBuildFileSetFactory(); - _console = console; _runtimeProcessLauncherFactory = runtimeProcessLauncherFactory; if (!context.Options.NonInteractive) @@ -62,7 +61,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) } await using var browserConnector = new BrowserConnector(_context); - using var fileWatcher = new FileWatcher(_context.Reporter); + using var fileWatcher = new FileWatcher(_context.Reporter, _context.EnvironmentOptions); for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++) { @@ -84,18 +83,25 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) try { var rootProjectOptions = _context.RootProjectOptions; - var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; + + var (buildSucceeded, buildOutput, _) = await BuildProjectAsync(rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); + BuildOutput.ReportBuildOutput(_context.Reporter, buildOutput, buildSucceeded, projectDisplay: rootProjectOptions.ProjectPath); + if (!buildSucceeded) + { + 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. - evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken); - Debug.Assert(evaluationResult.ProjectGraph != null); + // Avoid restore since the build above already restored the root project. + evaluationResult = await EvaluateRootProjectAsync(restore: false, iterationCancellationToken); var rootProject = evaluationResult.ProjectGraph.GraphRoots.Single(); // use normalized MSBuild path so that we can index into the ProjectGraph rootProjectOptions = rootProjectOptions with { ProjectPath = rootProject.ProjectInstance.FullPath }; + var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; var rootProjectCapabilities = rootProject.GetCapabilities(); if (rootProjectCapabilities.Contains(AspireServiceFactory.AppHostProjectCapability)) { @@ -105,13 +111,11 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Reporter); compilationHandler = new CompilationHandler(_context.Reporter, _context.ProcessRunner); - var scopedCssFileHandler = new ScopedCssFileHandler(_context.Reporter, projectMap, browserConnector); + var scopedCssFileHandler = new ScopedCssFileHandler(_context.Reporter, projectMap, browserConnector, _context.EnvironmentOptions); var projectLauncher = new ProjectLauncher(_context, projectMap, browserConnector, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Reporter); - var rootProjectNode = evaluationResult.ProjectGraph.GraphRoots.Single(); - - runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProjectNode, projectLauncher, rootProjectOptions); + runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); if (runtimeProcessLauncher != null) { var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables(); @@ -121,13 +125,6 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) }; } - var (buildSucceeded, buildOutput, _) = await BuildProjectAsync(rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, iterationCancellationToken); - BuildOutput.ReportBuildOutput(_context.Reporter, buildOutput, buildSucceeded, projectDisplay: rootProjectOptions.ProjectPath); - if (!buildSucceeded) - { - continue; - } - rootRunningProject = await projectLauncher.TryLaunchProcessAsync( rootProjectOptions, rootProcessTerminationSource, @@ -169,7 +166,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - await compilationHandler.Workspace.UpdateProjectConeAsync(_fileSetFactory.RootProjectFile, iterationCancellationToken); + await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, 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. @@ -205,6 +202,11 @@ void FileChangedCallback(ChangedPath change) { try { + if (Test_FileChangesCompletedTask != null) + { + await Test_FileChangesCompletedTask; + } + // Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check // for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again. _ = await rootRunningProject.RunningProcess.WaitAsync(TimeSpan.FromMilliseconds(extendTimeout ? 200 : 50), iterationCancellationToken); @@ -441,12 +443,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict _context.Reporter.Report(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation); // TODO: consider re-evaluating only affected projects instead of the whole graph. - evaluationResult = await EvaluateRootProjectAsync(iterationCancellationToken); + evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken); - // additional directories may have been added: + // additional files/directories may have been added: evaluationResult.WatchFiles(fileWatcher); - await compilationHandler.Workspace.UpdateProjectConeAsync(_fileSetFactory.RootProjectFile, iterationCancellationToken); + await compilationHandler.Workspace.UpdateProjectConeAsync(rootProjectOptions.ProjectPath, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -456,7 +458,8 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict // Update files in the change set with new evaluation info. changedFiles = [.. changedFiles - .Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f)]; + .Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f) + ]; _context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted); } @@ -490,7 +493,8 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict if (!evaluationRequired) { - // update the workspace to reflect changes in the file content: + // Update the workspace to reflect changes in the file content:. + // If the project was re-evaluated the Roslyn solution is already up to date. await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } @@ -568,7 +572,7 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche else { // evaluation cancelled - watch for any changes in the directory tree containing the root project: - fileWatcher.WatchContainingDirectories([_fileSetFactory.RootProjectFile], includeSubdirectories: true); + fileWatcher.WatchContainingDirectories([_context.RootProjectOptions.ProjectPath], includeSubdirectories: true); _ = await fileWatcher.WaitForFileChangeAsync( acceptChange: change => AcceptChange(change), @@ -577,9 +581,6 @@ private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatche } } - private Predicate CreateChangeFilter(EvaluationResult evaluationResult) - => new(change => AcceptChange(change, evaluationResult)); - private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult) { var (path, kind) = change; @@ -754,22 +755,29 @@ static string GetPluralMessage(ChangeKind kind) }; } - private async ValueTask EvaluateRootProjectAsync(CancellationToken cancellationToken) + private async ValueTask EvaluateRootProjectAsync(bool restore, CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); - var result = await _fileSetFactory.TryCreateAsync(requireProjectGraph: true, cancellationToken); + var result = EvaluationResult.TryCreate( + _context.RootProjectOptions.ProjectPath, + _context.RootProjectOptions.BuildArguments, + _context.Reporter, + _context.EnvironmentOptions, + restore, + cancellationToken); + if (result != null) { - Debug.Assert(result.ProjectGraph != null); return result; } await FileWatcher.WaitForFileChangeAsync( - _fileSetFactory.RootProjectFile, + _context.RootProjectOptions.ProjectPath, _context.Reporter, + _context.EnvironmentOptions, startedWatching: () => _context.Reporter.Report(MessageDescriptor.FixBuildError), cancellationToken); } @@ -780,6 +788,10 @@ await FileWatcher.WaitForFileChangeAsync( { var buildOutput = new List(); + string[] binLogArguments = _context.EnvironmentOptions.GetTestBinLogPath(projectPath, "Build") is { } binLogPath + ? [$"-bl:{binLogPath}"] + : []; + var processSpec = new ProcessSpec { Executable = _context.EnvironmentOptions.MuxerPath, @@ -792,7 +804,7 @@ await FileWatcher.WaitForFileChangeAsync( } }, // pass user-specified build arguments last to override defaults: - Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. buildArguments] + Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments] }; _context.Reporter.Output($"Building {projectPath} ..."); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs index 6c30ef91c0d9..b0c11f9815a9 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs @@ -2,14 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Build.Framework; using Microsoft.Build.Graph; namespace Microsoft.DotNet.Watch { - internal sealed class ScopedCssFileHandler(IReporter reporter, ProjectNodeMap projectMap, BrowserConnector browserConnector) + internal sealed class ScopedCssFileHandler(IReporter reporter, ProjectNodeMap projectMap, BrowserConnector browserConnector, EnvironmentOptions environmentOptions) { - private const string BuildTargetName = "GenerateComputedBuildStaticWebAssets"; + private const string BuildTargetName = TargetNames.GenerateComputedBuildStaticWebAssets; public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken) { @@ -54,20 +53,16 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList files, return; } - var logger = reporter.IsVerbose ? new[] { new Build.Logging.ConsoleLogger(LoggerVerbosity.Minimal) } : null; + var buildReporter = new BuildReporter(reporter, environmentOptions); var buildTasks = projectsToRefresh.Select(projectNode => Task.Run(() => { - try - { - if (!projectNode.ProjectInstance.DeepCopy().Build(BuildTargetName, logger)) - { - return null; - } - } - catch (Exception e) + using var loggers = buildReporter.GetLoggers(projectNode.ProjectInstance.FullPath, BuildTargetName); + + // Deep copy so that we don't pollute the project graph: + if (!projectNode.ProjectInstance.DeepCopy().Build(BuildTargetName, loggers)) { - reporter.Error($"[{projectNode.GetDisplayName()}] Target {BuildTargetName} failed to build: {e}"); + loggers.ReportOutput(); return null; } diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index a028bdfd26f6..89eb9630a4dd 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -244,9 +244,8 @@ private async Task ListFilesAsync(ProcessRunner processRunner, Cancellation var fileSetFactory = new MSBuildFileSetFactory( rootProjectOptions.ProjectPath, rootProjectOptions.BuildArguments, - environmentOptions, processRunner, - reporter); + new BuildReporter(reporter, environmentOptions)); if (await fileSetFactory.TryCreateAsync(requireProjectGraph: null, cancellationToken) is not { } evaluationResult) { diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index 219c522680d3..730989539fe7 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -2,15 +2,17 @@ "profiles": { "dotnet-watch": { "commandName": "Project", - "commandLineArgs": "--verbose /bl:DotnetRun.binlog -lp http --non-interactive", - "workingDirectory": "C:\\sdk1\\artifacts\\tmp\\Debug\\Aspire_BuildE---04F22750\\WatchAspire.AppHost", + "commandLineArgs": "--verbose /bl:DotnetRun.binlog", + "workingDirectory": "E:\\sdk\\artifacts\\tmp\\Debug\\Aspire_BuildE---04F22750\\WatchAspire.AppHost", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", "DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS": "100000", "DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000", "DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS": "0", - "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1" + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1", + "__DOTNET_WATCH_TEST_FLAGS": "RunningAsTest", + "__DOTNET_WATCH_TEST_OUTPUT_DIR": "$(RepoRoot)\\artifacts\\tmp\\Debug" } } } diff --git a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs deleted file mode 100644 index 780ae3477cdc..000000000000 --- a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Immutable; -using Microsoft.Build.Graph; -using Microsoft.DotNet.Cli; - -namespace Microsoft.DotNet.Watch; - -internal static class ProjectGraphNodeExtensions -{ - public static string GetDisplayName(this ProjectGraphNode projectNode) - => $"{Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath)} ({projectNode.GetTargetFramework()})"; - - public static string GetTargetFramework(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue("TargetFramework"); - - public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) - => EnvironmentVariableNames.TryParseTargetFrameworkVersion(projectNode.ProjectInstance.GetPropertyValue("TargetFrameworkVersion")); - - public static IEnumerable GetWebAssemblyCapabilities(this ProjectGraphNode projectNode) - => projectNode.GetStringListPropertyValue("WebAssemblyHotReloadCapabilities"); - - public static bool IsTargetFrameworkVersionOrNewer(this ProjectGraphNode projectNode, Version minVersion) - => GetTargetFrameworkVersion(projectNode) is { } version && version >= minVersion; - - public static bool IsNetCoreApp(string identifier) - => string.Equals(identifier, ".NETCoreApp", StringComparison.OrdinalIgnoreCase); - - public static bool IsNetCoreApp(this ProjectGraphNode projectNode) - => IsNetCoreApp(projectNode.ProjectInstance.GetPropertyValue("TargetFrameworkIdentifier")); - - public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVersion) - => IsNetCoreApp(projectNode) && IsTargetFrameworkVersionOrNewer(projectNode, minVersion); - - public static bool IsWebApp(this ProjectGraphNode projectNode) - => projectNode.GetCapabilities().Any(static value => value is "AspNetCore" or "WebAssembly"); - - public static string? GetOutputDirectory(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue("TargetPath") is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; - - public static string GetAssemblyName(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue("TargetName"); - - public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue("IntermediateOutputPath") is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null; - - public static IEnumerable GetCapabilities(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetItems("ProjectCapability").Select(item => item.EvaluatedInclude); - - public static bool IsAutoRestartEnabled(this ProjectGraphNode projectNode) - => projectNode.GetBooleanPropertyValue("HotReloadAutoRestart"); - - public static bool AreDefaultItemsEnabled(this ProjectGraphNode projectNode) - => projectNode.GetBooleanPropertyValue("EnableDefaultItems"); - - public static IEnumerable GetDefaultItemExcludes(this ProjectGraphNode projectNode) - => projectNode.GetStringListPropertyValue("DefaultItemExcludes"); - - private static IEnumerable GetStringListPropertyValue(this ProjectGraphNode projectNode, string propertyName) - => projectNode.ProjectInstance.GetPropertyValue(propertyName).Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - - private static bool GetBooleanPropertyValue(this ProjectGraphNode projectNode, string propertyName) - => bool.TryParse(projectNode.ProjectInstance.GetPropertyValue(propertyName), out var result) && result; - - public static IEnumerable GetTransitivelyReferencingProjects(this IEnumerable projects) - { - var visited = new HashSet(); - var queue = new Queue(); - foreach (var project in projects) - { - queue.Enqueue(project); - } - - while (queue.Count > 0) - { - var project = queue.Dequeue(); - if (visited.Add(project)) - { - foreach (var referencingProject in project.ReferencingProjects) - { - queue.Enqueue(referencingProject); - } - } - } - - return visited; - } -} diff --git a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs index 5a74aa5b032e..e056d122535b 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/BuildEvaluator.cs @@ -24,7 +24,7 @@ internal class BuildEvaluator private List<(string fileName, DateTime lastWriteTimeUtc)>? _msbuildFileTimestamps; // result of the last evaluation, or null if no evaluation has been performed yet. - private EvaluationResult? _evaluationResult; + private MSBuildFileSetFactory.EvaluationResult? _evaluationResult; public bool RequiresRevaluation { get; set; } @@ -35,7 +35,11 @@ public BuildEvaluator(DotNetWatchContext context) } protected virtual MSBuildFileSetFactory CreateMSBuildFileSetFactory() - => _context.CreateMSBuildFileSetFactory(); + => new( + _context.RootProjectOptions.ProjectPath, + _context.RootProjectOptions.BuildArguments, + _context.ProcessRunner, + new BuildReporter(_context.Reporter, _context.EnvironmentOptions)); public IReadOnlyList GetProcessArguments(int iteration) { @@ -57,7 +61,7 @@ public IReadOnlyList GetProcessArguments(int iteration) return [_context.RootProjectOptions.Command, .. _context.RootProjectOptions.CommandArguments]; } - public async ValueTask EvaluateAsync(ChangedFile? changedFile, CancellationToken cancellationToken) + public async ValueTask EvaluateAsync(ChangedFile? changedFile, CancellationToken cancellationToken) { if (_context.EnvironmentOptions.SuppressMSBuildIncrementalism) { @@ -83,7 +87,7 @@ public async ValueTask EvaluateAsync(ChangedFile? changedFile, return _evaluationResult; } - private async ValueTask CreateEvaluationResult(CancellationToken cancellationToken) + private async ValueTask CreateEvaluationResult(CancellationToken cancellationToken) { while (true) { @@ -98,6 +102,7 @@ private async ValueTask CreateEvaluationResult(CancellationTok await FileWatcher.WaitForFileChangeAsync( _fileSetFactory.RootProjectFile, _context.Reporter, + _context.EnvironmentOptions, startedWatching: () => _context.Reporter.Report(MessageDescriptor.FixBuildError), cancellationToken); } @@ -131,7 +136,7 @@ private bool RequiresMSBuildRevaluation(FileItem? changedFile) return false; } - private List<(string fileName, DateTime lastModifiedUtc)> GetMSBuildFileTimeStamps(EvaluationResult result) + private List<(string fileName, DateTime lastModifiedUtc)> GetMSBuildFileTimeStamps(MSBuildFileSetFactory.EvaluationResult result) { var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>(); foreach (var (filePath, _) in result.Files) diff --git a/src/BuiltInTools/dotnet-watch/Build/DotNetWatch.targets b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatch.targets similarity index 100% rename from src/BuiltInTools/dotnet-watch/Build/DotNetWatch.targets rename to src/BuiltInTools/dotnet-watch/Watch/DotNetWatch.targets diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index 11069f4450c6..1c2b25c8de25 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -77,7 +77,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke using var currentRunCancellationSource = new CancellationTokenSource(); using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token); - using var fileSetWatcher = new FileWatcher(context.Reporter); + using var fileSetWatcher = new FileWatcher(context.Reporter, context.EnvironmentOptions); fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true); diff --git a/src/BuiltInTools/dotnet-watch/Build/MSBuildFileSetResult.cs b/src/BuiltInTools/dotnet-watch/Watch/MSBuildFileSetResult.cs similarity index 100% rename from src/BuiltInTools/dotnet-watch/Build/MSBuildFileSetResult.cs rename to src/BuiltInTools/dotnet-watch/Watch/MSBuildFileSetResult.cs diff --git a/src/BuiltInTools/dotnet-watch/Build/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs similarity index 70% rename from src/BuiltInTools/dotnet-watch/Build/MsBuildFileSetFactory.cs rename to src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 008634208eee..3421ec498ec4 100644 --- a/src/BuiltInTools/dotnet-watch/Build/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -1,12 +1,9 @@ // 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.Concurrent; +using System.Collections.Immutable; using System.Diagnostics; using System.Text.Json; -using Microsoft.Build.Evaluation; -using Microsoft.Build.Execution; -using Microsoft.Build.FileSystem; using Microsoft.Build.Graph; namespace Microsoft.DotNet.Watch @@ -17,20 +14,26 @@ namespace Microsoft.DotNet.Watch /// Invokes msbuild to evaluate on the root project, which traverses all project dependencies and collects /// items that are to be watched. The target can be customized by defining CustomCollectWatchItems target. This is currently done by Razor SDK /// to collect razor and cshtml files. - /// /// Consider replacing with traversal (https://github.com/dotnet/sdk/issues/40214). /// internal class MSBuildFileSetFactory( string rootProjectFile, IEnumerable buildArguments, - EnvironmentOptions environmentOptions, ProcessRunner processRunner, - IReporter reporter) + BuildReporter buildReporter) { private const string TargetName = "GenerateWatchList"; private const string WatchTargetsFileName = "DotNetWatch.targets"; public string RootProjectFile => rootProjectFile; + private EnvironmentOptions EnvironmentOptions => buildReporter.EnvironmentOptions; + private IReporter Reporter => buildReporter.Reporter; + + internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) + { + public readonly IReadOnlyDictionary Files = files; + public readonly ProjectGraph? ProjectGraph = projectGraph; + } // Virtual for testing. public virtual async ValueTask TryCreateAsync(bool? requireProjectGraph, CancellationToken cancellationToken) @@ -44,7 +47,7 @@ internal class MSBuildFileSetFactory( var processSpec = new ProcessSpec { - Executable = environmentOptions.MuxerPath, + Executable = EnvironmentOptions.MuxerPath, WorkingDirectory = projectDir, Arguments = arguments, OnOutput = line => @@ -56,19 +59,19 @@ internal class MSBuildFileSetFactory( } }; - reporter.Verbose($"Running MSBuild target '{TargetName}' on '{rootProjectFile}'"); + Reporter.Verbose($"Running MSBuild target '{TargetName}' on '{rootProjectFile}'"); - var exitCode = await processRunner.RunAsync(processSpec, reporter, isUserApplication: false, launchResult: null, cancellationToken); + var exitCode = await processRunner.RunAsync(processSpec, Reporter, isUserApplication: false, launchResult: null, cancellationToken); var success = exitCode == 0 && File.Exists(watchList); if (!success) { - reporter.Error($"Error(s) finding watch items project file '{Path.GetFileName(rootProjectFile)}'."); - reporter.Output($"MSBuild output from target '{TargetName}':"); + Reporter.Error($"Error(s) finding watch items project file '{Path.GetFileName(rootProjectFile)}'."); + Reporter.Output($"MSBuild output from target '{TargetName}':"); } - BuildOutput.ReportBuildOutput(reporter, capturedOutput, success, projectDisplay: null); + BuildOutput.ReportBuildOutput(Reporter, capturedOutput, success, projectDisplay: null); if (!success) { return null; @@ -110,14 +113,8 @@ void AddFile(string filePath, string? staticWebAssetPath) } } - reporter.Verbose($"Watching {fileItems.Count} file(s) for changes"); + buildReporter.ReportWatchedFiles(fileItems); #if DEBUG - - foreach (var file in fileItems.Values) - { - reporter.Verbose($" -> {file.FilePath} {file.StaticWebAssetPath}"); - } - Debug.Assert(fileItems.Values.All(f => Path.IsPathRooted(f.FilePath)), "All files should be rooted paths"); #endif @@ -125,7 +122,10 @@ void AddFile(string filePath, string? staticWebAssetPath) ProjectGraph? projectGraph = null; if (requireProjectGraph != null) { - projectGraph = TryLoadProjectGraph(requireProjectGraph.Value, cancellationToken); + var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) + .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value); + + projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(rootProjectFile, globalOptions, Reporter, requireProjectGraph.Value, cancellationToken); if (projectGraph == null && requireProjectGraph == true) { return null; @@ -154,11 +154,9 @@ private IReadOnlyList GetMSBuildArguments(string watchListFilePath) "/t:" + TargetName }; -#if !DEBUG - if (environmentOptions.TestFlags.HasFlag(TestFlags.RunningAsTest)) -#endif + if (EnvironmentOptions.GetTestBinLogPath(rootProjectFile, "GenerateWatchList") is { } binLogPath) { - arguments.Add($"/bl:{Path.Combine(environmentOptions.TestOutput, "DotnetWatch.GenerateWatchList.binlog")}"); + arguments.Add($"/bl:{binLogPath}"); } arguments.AddRange(buildArguments); @@ -166,7 +164,7 @@ private IReadOnlyList GetMSBuildArguments(string watchListFilePath) // Set dotnet-watch reserved properties after the user specified propeties, // so that the former take precedence. - if (environmentOptions.SuppressHandlingStaticContentFiles) + if (EnvironmentOptions.SuppressHandlingStaticContentFiles) { arguments.Add("/p:DotNetWatchContentFiles=false"); } @@ -196,57 +194,5 @@ private static string FindTargetsFile() var targetPath = searchPaths.Select(p => Path.Combine(p, WatchTargetsFileName)).FirstOrDefault(File.Exists); return targetPath ?? throw new FileNotFoundException("Fatal error: could not find DotNetWatch.targets"); } - - // internal for testing - - /// - /// Tries to create a project graph by running the build evaluation phase on the . - /// - internal ProjectGraph? TryLoadProjectGraph(bool projectGraphRequired, CancellationToken cancellationToken) - { - var globalOptions = new Dictionary(); - - foreach (var (name, value) in CommandLineOptions.ParseBuildProperties(buildArguments)) - { - globalOptions[name] = value; - } - - var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); - - try - { - return new ProjectGraph([entryPoint], ProjectCollection.GlobalProjectCollection, projectInstanceFactory: null, cancellationToken); - } - catch (Exception e) - { - reporter.Verbose("Failed to load project graph."); - - if (e is AggregateException { InnerExceptions: var innerExceptions }) - { - foreach (var inner in innerExceptions) - { - Report(inner); - } - } - else - { - Report(e); - } - - void Report(Exception e) - { - if (projectGraphRequired) - { - reporter.Error(e.Message); - } - else - { - reporter.Warn(e.Message); - } - } - } - - return null; - } } } diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index a401dd5667ab..a17d1ca4d04a 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -29,7 +29,7 @@ - + diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockBuildEngine.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockBuildEngine.cs index 95c9b92ade73..bf648190f912 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockBuildEngine.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockBuildEngine.cs @@ -8,7 +8,11 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { - public class MockBuildEngine : IBuildEngine4 + /// + /// Must be internal to avoid loading msbuild assemblies before the test assembly, + /// which might need to set the msbuild location. + /// + internal class MockBuildEngine : IBuildEngine4 { public int ColumnNumberOfTaskNode { get; set; } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockTaskItem.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockTaskItem.cs index f76842199892..59dda4ada48f 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockTaskItem.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/Mocks/MockTaskItem.cs @@ -8,7 +8,11 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { - public class MockTaskItem : ITaskItem + /// + /// Must be internal to avoid loading msbuild assemblies before the test assembly, + /// which might need to set the msbuild location. + /// + internal class MockTaskItem : ITaskItem { private Dictionary _metadata = new(StringComparer.OrdinalIgnoreCase); diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj index 10b1abfc7ab5..8b77b42790a9 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj +++ b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToFloatWarningLevels.cs b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToFloatWarningLevels.cs index 1b8f6347860e..dce92c1248c8 100644 --- a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToFloatWarningLevels.cs +++ b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToFloatWarningLevels.cs @@ -45,7 +45,7 @@ static void Main() }; var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "warningLevelConsoleApp" + tfm, targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "warningLevelConsoleApp" + tfm); var buildCommand = new GetValuesCommand( Log, @@ -89,7 +89,7 @@ static void Main() }; testProject.AdditionalProperties.Add("WarningLevel", warningLevel?.ToString()); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "customWarningLevelConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "customWarningLevelConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -134,7 +134,7 @@ static void Main() }; var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analysisLevelConsoleApp" + tfm, targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analysisLevelConsoleApp" + tfm); var buildCommand = new GetValuesCommand( Log, @@ -190,7 +190,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "preview"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + currentTFM, targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + currentTFM); var buildCommand = new GetValuesCommand( Log, @@ -237,7 +237,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", analysisLevel); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel, targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel); var buildCommand = new GetValuesCommand( Log, @@ -333,7 +333,7 @@ static void Main() testProject.AdditionalProperties.Add("CodeAnalysisTreatWarningsAsErrors", codeAnalysisTreatWarningsAsErrors); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel + category, targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analysisLevelPreviewConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel + category); var buildCommand = new GetValuesCommand( Log, @@ -458,7 +458,7 @@ public static void CA1031_All() testProject.AdditionalProperties.Add("NoWarn", "CS9057"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analysisLevelConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel + $"Warnaserror:{codeAnalysisTreatWarningsAsErrors}", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analysisLevelConsoleApp" + ToolsetInfo.CurrentTargetFramework + analysisLevel + $"Warnaserror:{codeAnalysisTreatWarningsAsErrors}"); var buildCommand = new BuildCommand(Log, Path.Combine(testAsset.TestRoot, testProject.Name)); var buildResult = buildCommand.Execute(); diff --git a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUsePlatformAnalyzers.cs b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUsePlatformAnalyzers.cs index a22b1c84446b..758382bff059 100644 --- a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUsePlatformAnalyzers.cs +++ b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUsePlatformAnalyzers.cs @@ -43,7 +43,7 @@ static void Main() }; var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -89,7 +89,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "4"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -135,7 +135,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "5"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -181,7 +181,7 @@ static void Main() testProject.AdditionalProperties.Add("EnableNETAnalyzers", "false"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -227,7 +227,7 @@ static void Main() testProject.AdditionalProperties.Add("EnableNETAnalyzers", "true"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -274,7 +274,7 @@ static void Main() testProject.AdditionalProperties.Add("CodeAnalysisTreatWarningsAsErrors", "false"); testProject.AdditionalProperties.Add("TreatWarningsAsErrors", "true"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new BuildCommand(Log, Path.Combine(testAsset.TestRoot, testProject.Name)); buildCommand.Execute().Should().Pass(); @@ -312,7 +312,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "none"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -358,7 +358,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "latest"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, @@ -404,7 +404,7 @@ static void Main() testProject.AdditionalProperties.Add("AnalysisLevel", "latest"); var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: "analyzerConsoleApp", targetExtension: ".csproj"); + .CreateTestProject(testProject, identifier: "analyzerConsoleApp"); var buildCommand = new GetValuesCommand( Log, diff --git a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUseVB.cs b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUseVB.cs index 4b0bc2639026..e3195b782763 100644 --- a/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUseVB.cs +++ b/test/Microsoft.NET.Build.Tests/GivenThatWeWantToUseVB.cs @@ -37,6 +37,7 @@ public void It_builds_a_simple_vb_project(string targetFramework, bool isExe) Name = "HelloWorld", TargetFrameworks = targetFramework, IsExe = isExe, + TargetExtension = ".vbproj", SourceFiles = { ["Program.vb"] = @" @@ -68,7 +69,7 @@ End Module }; var testAsset = _testAssetsManager - .CreateTestProject(testProject, identifier: targetFramework + isExe, targetExtension: ".vbproj"); + .CreateTestProject(testProject, identifier: targetFramework + isExe); var buildCommand = new GetValuesCommand( Log, @@ -103,7 +104,7 @@ private static (VBRuntime, string[]) GetExpectedOutputs(string targetFramework, "HelloWorld.exe.config", "HelloWorld.pdb" }; - if (TestProject.ReferenceAssembliesAreInstalled(TargetDotNetFrameworkVersion.Version471)) + if (ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(TargetDotNetFrameworkVersion.Version471) != null) { return (VBRuntime.Default, files); } diff --git a/test/Microsoft.NET.Build.Tests/RoslynBuildTaskTests.cs b/test/Microsoft.NET.Build.Tests/RoslynBuildTaskTests.cs index ad760834bae2..ba14cd54eebc 100644 --- a/test/Microsoft.NET.Build.Tests/RoslynBuildTaskTests.cs +++ b/test/Microsoft.NET.Build.Tests/RoslynBuildTaskTests.cs @@ -105,6 +105,7 @@ End Module { Name = "App1", IsExe = true, + TargetExtension = projExtension, SourceFiles = { [sourceName] = sourceText, @@ -118,7 +119,7 @@ End Module } configure?.Invoke(project); - return _testAssetsManager.CreateTestProject(project, callingMethod: callingMethod, targetExtension: projExtension); + return _testAssetsManager.CreateTestProject(project, callingMethod: callingMethod); } private static void AddCompilersToolsetPackage(TestProject project) diff --git a/test/Microsoft.NET.Restore.Tests/GivenThatWeWantAutomaticTargetingPackReferences.cs b/test/Microsoft.NET.Restore.Tests/GivenThatWeWantAutomaticTargetingPackReferences.cs index 3ed666a35bba..b5cdde0bf1c5 100644 --- a/test/Microsoft.NET.Restore.Tests/GivenThatWeWantAutomaticTargetingPackReferences.cs +++ b/test/Microsoft.NET.Restore.Tests/GivenThatWeWantAutomaticTargetingPackReferences.cs @@ -3,10 +3,10 @@ #nullable disable -using Microsoft.Build.Utilities; using NuGet.Common; using NuGet.Frameworks; using NuGet.ProjectModel; +using Microsoft.Build.Utilities; namespace Microsoft.NET.Restore.Tests { @@ -46,7 +46,7 @@ public void It_restores_net_framework_project_successfully(string version) LockFile lockFile = LockFileUtilities.GetLockFile(projectAssetsJsonPath, NullLogger.Instance); var netFrameworkLibrary = lockFile.GetTarget(NuGetFramework.Parse(".NETFramework,Version=v" + version), null).Libraries.FirstOrDefault((file) => file.Name.Contains(targetFramework)); - if (TestProject.ReferenceAssembliesAreInstalled(targetFrameworkVersion)) + if (ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(targetFrameworkVersion) != null) { netFrameworkLibrary.Should().BeNull(); } @@ -104,7 +104,7 @@ public void It_restores_multitargeted_net_framework_project_successfully(bool in NullLogger.Instance); var net471FrameworkLibrary = lockFile.GetTarget(NuGetFramework.Parse(".NETFramework,Version=v4.7.1"), null).Libraries.FirstOrDefault((file) => file.Name.Contains("net471")); - if (TestProject.ReferenceAssembliesAreInstalled(TargetDotNetFrameworkVersion.Version471) && !includeExplicitReference) + if (ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(TargetDotNetFrameworkVersion.Version471) != null && !includeExplicitReference) { net471FrameworkLibrary.Should().BeNull(); } @@ -116,7 +116,7 @@ public void It_restores_multitargeted_net_framework_project_successfully(bool in var net472FrameworkLibrary = lockFile.GetTarget(NuGetFramework.Parse(".NETFramework,Version=v4.7.2"), null).Libraries.FirstOrDefault((file) => file.Name.Contains("net472")); - if (TestProject.ReferenceAssembliesAreInstalled(TargetDotNetFrameworkVersion.Version472) && !includeExplicitReference) + if (ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(TargetDotNetFrameworkVersion.Version472) != null && !includeExplicitReference) { net472FrameworkLibrary.Should().BeNull(); } @@ -189,7 +189,7 @@ public void It_fails_without_assembly_pack_reference() var testAsset = _testAssetsManager.CreateTestProject(testProject); var buildCommand = new BuildCommand(testAsset); - if (TestProject.ReferenceAssembliesAreInstalled(TargetDotNetFrameworkVersion.Version472)) + if (ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(TargetDotNetFrameworkVersion.Version472) != null) { buildCommand.Execute() .Should() diff --git a/test/Microsoft.NET.Restore.Tests/Microsoft.NET.Restore.Tests.csproj b/test/Microsoft.NET.Restore.Tests/Microsoft.NET.Restore.Tests.csproj index 9d797145d653..ba4c241ee8e7 100644 --- a/test/Microsoft.NET.Restore.Tests/Microsoft.NET.Restore.Tests.csproj +++ b/test/Microsoft.NET.Restore.Tests/Microsoft.NET.Restore.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj index 8237a1b38f1e..fef4cf243d82 100644 --- a/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj +++ b/test/Microsoft.NET.TestFramework/Microsoft.NET.TestFramework.csproj @@ -40,7 +40,11 @@ - + + diff --git a/test/Microsoft.NET.TestFramework/ProjectConstruction/TestProject.cs b/test/Microsoft.NET.TestFramework/ProjectConstruction/TestProject.cs index 175b281218b9..9a1653b3e635 100644 --- a/test/Microsoft.NET.TestFramework/ProjectConstruction/TestProject.cs +++ b/test/Microsoft.NET.TestFramework/ProjectConstruction/TestProject.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using Microsoft.Build.Utilities; using NuGet.Frameworks; namespace Microsoft.NET.TestFramework.ProjectConstruction @@ -24,6 +23,8 @@ public TestProject([CallerMemberName] string? name = null) /// public string? Name { get; set; } + public string TargetExtension { get; set; } = ".csproj"; + public bool IsSdkProject { get; set; } = true; public bool IsExe { get; set; } @@ -56,7 +57,7 @@ public TestProject([CallerMemberName] string? name = null) public bool UseArtifactsOutput { get; set; } - public List ReferencedProjects { get; } = new List(); + public List ReferencedProjects { get; } = []; public List References { get; } = new List(); @@ -126,12 +127,12 @@ public bool BuildsOnNonWindows } } - internal void Create(TestAsset targetTestAsset, string testProjectsSourceFolder, string targetExtension = ".csproj") + internal void Create(TestAsset targetTestAsset, string testProjectsSourceFolder) { string targetFolder = Path.Combine(targetTestAsset.Path, Name ?? string.Empty); Directory.CreateDirectory(targetFolder); - string targetProjectPath = Path.Combine(targetFolder, Name + targetExtension); + string targetProjectPath = Path.Combine(targetFolder, Name + TargetExtension); string sourceProject; string sourceProjectBase = Path.Combine(testProjectsSourceFolder, "ProjectConstruction"); @@ -139,7 +140,7 @@ internal void Create(TestAsset targetTestAsset, string testProjectsSourceFolder, { sourceProject = Path.Combine(sourceProjectBase, "SdkProject", "SdkProject.csproj"); } - else if (targetExtension == ".vbproj") + else if (TargetExtension == ".vbproj") { sourceProject = Path.Combine(sourceProjectBase, "NetFrameworkProjectVB", "NetFrameworkProject.vbproj"); } @@ -309,7 +310,7 @@ internal void Create(TestAsset targetTestAsset, string testProjectsSourceFolder, foreach (var referencedProject in ReferencedProjects) { projectReferenceItemGroup.Add(new XElement(ns + "ProjectReference", - new XAttribute("Include", $"../{referencedProject?.Name}/{referencedProject?.Name}.csproj"))); + new XAttribute("Include", $"../{referencedProject.Name}/{referencedProject.Name}{referencedProject.TargetExtension}"))); } } @@ -425,7 +426,7 @@ static void Main(string[] args) foreach (var dependency in ReferencedProjects) { - string? safeDependencyName = dependency?.Name?.Replace('.', '_'); + string? safeDependencyName = dependency.Name?.Replace('.', '_'); source += $" Console.WriteLine({safeDependencyName}.{safeDependencyName}Class.Name);" + Environment.NewLine; source += $" Console.WriteLine({safeDependencyName}.{safeDependencyName}Class.List);" + Environment.NewLine; @@ -454,7 +455,7 @@ public class {safeThisName}Class "; foreach (var dependency in ReferencedProjects) { - string? safeDependencyName = dependency?.Name?.Replace('.', '_'); + string? safeDependencyName = dependency.Name?.Replace('.', '_'); source += $" public string {safeDependencyName}Name {{ get {{ return {safeDependencyName}.{safeDependencyName}Class.Name; }} }}" + Environment.NewLine; source += $" public List {safeDependencyName}List {{ get {{ return {safeDependencyName}.{safeDependencyName}Class.List; }} }}" + Environment.NewLine; @@ -464,22 +465,15 @@ public class {safeThisName}Class @" } }"; string sourcePath = Path.Combine(targetFolder, Name + ".cs"); - File.WriteAllText(sourcePath, source); } - - } - else - { - foreach (var kvp in SourceFiles) - { - File.WriteAllText(Path.Combine(targetFolder, kvp.Key), kvp.Value); - } } - foreach (var kvp in EmbeddedResources) + foreach (var kvp in SourceFiles.Concat(EmbeddedResources)) { - File.WriteAllText(Path.Combine(targetFolder, kvp.Key), kvp.Value); + var targetPath = Path.Combine(targetFolder, kvp.Key); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, kvp.Value); } } @@ -522,12 +516,6 @@ public Dictionary GetPropertyValues(string testRoot, string? tar return propertyValues; } - public static bool ReferenceAssembliesAreInstalled(TargetDotNetFrameworkVersion targetFrameworkVersion) - { - var referenceAssemblies = ToolLocationHelper.GetPathToDotNetFrameworkReferenceAssemblies(targetFrameworkVersion); - return referenceAssemblies != null; - } - private OutputPathCalculator GetOutputPathCalculator(string testRoot) { return OutputPathCalculator.FromProject(Path.Combine(testRoot, Name ?? string.Empty, Name + ".csproj"), this); diff --git a/test/Microsoft.NET.TestFramework/TestAssetsManager.cs b/test/Microsoft.NET.TestFramework/TestAssetsManager.cs index 52809ebe67ca..097b17c004b7 100644 --- a/test/Microsoft.NET.TestFramework/TestAssetsManager.cs +++ b/test/Microsoft.NET.TestFramework/TestAssetsManager.cs @@ -56,19 +56,17 @@ public TestAsset CopyTestAsset( /// Pass in the unique theory parameters that can indentify that theory from others. /// The Identifier is used to distinguish between theory child tests. Generally it should be created using a combination of all of the theory parameter values. /// This is distinct from the test project name and is used to prevent file collisions between theory tests that use the same test project. - /// The extension type of the desired test project, e.g. .csproj, or .fsproj. /// A new TestAsset directory for the TestProject. public TestAsset CreateTestProject( TestProject testProject, [CallerMemberName] string callingMethod = "", - string? identifier = "", - string targetExtension = ".csproj") + string? identifier = "") { var testDestinationDirectory = GetTestDestinationDirectoryPath(testProject.Name, callingMethod, identifier); TestDestinationDirectories.Add(testDestinationDirectory); - var testAsset = CreateTestProjectsInDirectory(new List() { testProject }, testDestinationDirectory, targetExtension); + var testAsset = CreateTestProjectsInDirectory(new List() { testProject }, testDestinationDirectory); testAsset.TestProject = testProject; return testAsset; @@ -89,14 +87,13 @@ public TestAsset CreateTestProject( public TestAsset CreateTestProjects( IEnumerable testProjects, [CallerMemberName] string callingMethod = "", - string identifier = "", - string targetExtension = ".csproj") + string identifier = "") { var testDestinationDirectory = GetTestDestinationDirectoryPath(callingMethod, callingMethod, identifier); TestDestinationDirectories.Add(testDestinationDirectory); - var testAsset = CreateTestProjectsInDirectory(testProjects, testDestinationDirectory, targetExtension); + var testAsset = CreateTestProjectsInDirectory(testProjects, testDestinationDirectory); var slnCreationResult = new DotnetNewCommand(Log, "sln") .WithVirtualHive() @@ -122,8 +119,7 @@ public TestAsset CreateTestProjects( private TestAsset CreateTestProjectsInDirectory( IEnumerable testProjects, - string testDestinationDirectory, - string targetExtension = ".csproj") + string testDestinationDirectory) { var testAsset = new TestAsset(testDestinationDirectory, TestContext.Current.SdkVersion, Log); @@ -135,15 +131,12 @@ private TestAsset CreateTestProjectsInDirectory( var project = projectStack.Pop(); if (!createdProjects.Contains(project)) { - project.Create(testAsset, TestAssetsRoot, targetExtension); + project.Create(testAsset, TestAssetsRoot); createdProjects.Add(project); foreach (var referencedProject in project.ReferencedProjects) { - if(referencedProject is not null) - { - projectStack.Push(referencedProject); - } + projectStack.Push(referencedProject); } } } diff --git a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs index 6290312c17d1..f1ef4c287e1d 100644 --- a/test/Microsoft.NET.TestFramework/ToolsetInfo.cs +++ b/test/Microsoft.NET.TestFramework/ToolsetInfo.cs @@ -10,6 +10,7 @@ public class ToolsetInfo { public const string CurrentTargetFramework = "net10.0"; public const string CurrentTargetFrameworkVersion = "10.0"; + public const string CurrentTargetFrameworkMoniker = ".NETCoreApp,Version=v" + CurrentTargetFrameworkVersion; public const string NextTargetFramework = "net11.0"; public const string NextTargetFrameworkVersion = "11.0"; @@ -404,18 +405,19 @@ private bool UsingFullMSBuildWithoutExtensionsTargets() .Where(a => a.Key is not null && a.Key.EndsWith("PackageVersion")) .Select(a => (a.Key ?? string.Empty, a.Value ?? string.Empty)); - private static readonly Lazy _NewtonsoftJsonPackageVersion = new Lazy(() => GetPackageVersionProperties().Single(p => p.versionPropertyName == "NewtonsoftJsonPackageVersion").version); - - public static string GetNewtonsoftJsonPackageVersion() + public static string GetPackageVersion(string packageName) { - return _NewtonsoftJsonPackageVersion.Value; + var propertyName = packageName.Replace(".", "") + "PackageVersion"; + return GetPackageVersionProperties().Single(p => p.versionPropertyName == propertyName).version; } - private static readonly Lazy _SystemDataSqlClientPackageVersion = new(() => GetPackageVersionProperties().Single(p => p.versionPropertyName == "SystemDataSqlClientPackageVersion").version); + private static readonly Lazy s_newtonsoftJsonPackageVersion = new(() => GetPackageVersion("Newtonsoft.Json")); + private static readonly Lazy s_systemDataSqlClientPackageVersion = new(() => GetPackageVersion("System.Data.SqlClient")); + + public static string GetNewtonsoftJsonPackageVersion() + =>s_newtonsoftJsonPackageVersion.Value; public static string GetSystemDataSqlClientPackageVersion() - { - return _SystemDataSqlClientPackageVersion.Value; - } + => s_systemDataSqlClientPackageVersion.Value; } } diff --git a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs index 4162119930ba..f1d2a3b77bbd 100644 --- a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs +++ b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs @@ -26,8 +26,14 @@ while (true) { - Console.WriteLine("."); + F(); await Task.Delay(1000); } +void F() +{ + Console.WriteLine("."); +} + class C { /* member placeholder */ } + diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs new file mode 100644 index 000000000000..3d6970ec5ac9 --- /dev/null +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -0,0 +1,575 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace Microsoft.DotNet.Watch.UnitTests +{ + public class EvaluationTests(ITestOutputHelper output) + { + private readonly TestReporter _reporter = new(output); + private readonly TestAssetsManager _testAssets = new(output); + + private static string MuxerPath + => TestContext.Current.ToolsetUnderTest.DotNetHostPath; + + private static string InspectPath(string path, string rootDir) + => path.Substring(rootDir.Length + 1).Replace("\\", "/"); + + private static IEnumerable Inspect(string rootDir, IReadOnlyDictionary files) + => files + .OrderBy(entry => entry.Key) + .Select(entry => $"{InspectPath(entry.Key, rootDir)}: [{string.Join(", ", entry.Value.ContainingProjectPaths.Select(p => InspectPath(p, rootDir)))}]"); + + private static readonly string s_emptyResx = """ + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + """; + + private static readonly string s_emptyProgram = """ + return 1; + """; + + [Fact] + public async Task FindsCustomWatchItems() + { + var project = new TestProject("Project1") + { + IsExe = true, + SourceFiles = + { + {"Program.cs", s_emptyProgram}, + {"app.js", ""}, + {"gulpfile.js", ""}, + }, + EmbeddedResources = + { + {"Strings.resx", s_emptyResx} + } + }; + + var testAsset = _testAssets.CreateTestProject(project) + .WithProjectChanges(d => d.Root!.Add(XElement.Parse(""" + + + + """))); + + await VerifyEvaluation(testAsset, + [ + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Program.cs"), + new("Project1/app.js"), + new("Project1/Strings.resx", targetsOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + ]); + } + + [Fact] + public async Task ExcludesDefaultItemsWithWatchFalseMetadata() + { + var project = new TestProject("Project1") + { + IsExe = true, + AdditionalProperties = + { + ["EnableDefaultEmbeddedResourceItems"] = "false", + ["EnableDefaultCompileItems"] = "false", + }, + AdditionalItems = + { + new("EmbeddedResource", new() { { "Include", "*.resx" }, { "Watch", "false" } }), + new("Compile", new() { { "Include", "Program.cs" } }), + new("Compile", new() { { "Include", "Class*.cs" }, { "Watch", "false" } }), + }, + SourceFiles = + { + {"Program.cs", s_emptyProgram}, + {"Class1.cs", ""}, + {"Class2.cs", ""}, + }, + EmbeddedResources = + { + {"Strings.resx", s_emptyResx }, + } + }; + + var testAsset = _testAssets.CreateTestProject(project); + + await VerifyEvaluation(testAsset, + [ + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Program.cs"), + new("Project1/Class1.cs", graphOnly: true), + new("Project1/Class2.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + ]); + } + + [Theory] + [CombinatorialData] + public async Task StaticAssets(bool isWeb, [CombinatorialValues(true, false, null)] bool? enableContentFiles) + { + var project = new TestProject("Project1") + { + ProjectSdk = isWeb ? "Microsoft.NET.Sdk.Web" : "Microsoft.NET.Sdk", + IsExe = true, + SourceFiles = + { + {"Program.cs", s_emptyProgram}, + {"wwwroot/css/app.css", ""}, + {"wwwroot/js/site.js", ""}, + {"wwwroot/favicon.ico", ""}, + }, + AdditionalProperties = + { + ["DotNetWatchContentFiles"] = enableContentFiles?.ToString() ?? "", + }, + }; + + var testAsset = _testAssets.CreateTestProject(project, identifier: enableContentFiles.ToString()); + + await VerifyEvaluation(testAsset, + isWeb && enableContentFiles != false ? + [ + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Program.cs"), + new("Project1/wwwroot/css/app.css", staticAssetUrl: "wwwroot/css/app.css"), + new("Project1/wwwroot/js/site.js", staticAssetUrl: "wwwroot/js/site.js"), + new("Project1/wwwroot/favicon.ico", staticAssetUrl: "wwwroot/favicon.ico"), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + ] : + [ + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Program.cs"), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + ]); + } + + [Fact] + public async Task RazorClassLibrary() + { + var projectRcl = new TestProject("RCL") + { + ProjectSdk = "Microsoft.NET.Sdk.Razor", + PackageReferences = + { + new("Microsoft.AspNetCore.Components.Web", ToolsetInfo.GetPackageVersion("Microsoft.AspNetCore.App.Ref")), + new("Microsoft.AspNetCore.Mvc", "2.3.0"), + }, + SourceFiles = + { + {"Code.cs", ""}, + {"Page1.razor", ""}, + {"Page1.razor.css", ""}, + {"Page2.cshtml", "" }, + {"Page2.cshtml.css", "" }, + {"wwwroot/lib.css", ""}, + {"wwwroot/lib.js", ""}, + } + }; + + var project = new TestProject("Project1") + { + ProjectSdk = "Microsoft.NET.Sdk.Web", + IsExe = true, + ReferencedProjects = { projectRcl }, + SourceFiles = + { + {"Program.cs", s_emptyProgram}, + {"wwwroot/css/app.css", ""}, + {"wwwroot/js/site.js", ""}, + {"wwwroot/favicon.ico", ""}, + } + }; + + var testAsset = _testAssets.CreateTestProject(project); + + await VerifyEvaluation(testAsset, + [ + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + new("Project1/Program.cs"), + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/wwwroot/css/app.css", "wwwroot/css/app.css"), + new("Project1/wwwroot/favicon.ico", "wwwroot/favicon.ico"), + new("Project1/wwwroot/js/site.js", "wwwroot/js/site.js"), + new("RCL/Code.cs"), + new($"RCL/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"RCL/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/RCL.AssemblyInfo.cs", graphOnly: true), + new("RCL/Page1.razor"), + new("RCL/Page1.razor.css"), + new("RCL/Page2.cshtml"), + new("RCL/Page2.cshtml.css"), + new("RCL/RCL.csproj", targetsOnly: true), + new("RCL/wwwroot/lib.css", "wwwroot/lib.css"), + new("RCL/wwwroot/lib.js", "wwwroot/lib.js"), + ]); + } + + [Fact] + public async Task ProjectReferences_OneLevel() + { + var project2 = new TestProject("Project2") + { + TargetFrameworks = "netstandard2.0", + }; + + var project1 = new TestProject("Project1") + { + TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", + ReferencedProjects = { project2 }, + }; + + var testAsset = _testAssets.CreateTestProject(project1); + + await VerifyEvaluation(testAsset, targetFramework: ToolsetInfo.CurrentTargetFramework, + [ + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Project1.cs"), + new($"Project2/obj/Debug/netstandard2.0/.NETStandard,Version=v2.0.AssemblyAttributes.cs", graphOnly: true), + new($"Project2/obj/Debug/netstandard2.0/Project2.AssemblyInfo.cs", graphOnly: true), + new("Project2/Project2.csproj", targetsOnly: true), + new("Project2/Project2.cs"), + ]); + } + + [Fact] + public async Task TransitiveProjectReferences_TwoLevels() + { + var project3 = new TestProject("Project3") + { + TargetFrameworks = "netstandard2.0", + }; + + var project2 = new TestProject("Project2") + { + TargetFrameworks = "netstandard2.0", + ReferencedProjects = { project3 }, + }; + + var project1 = new TestProject("Project1") + { + TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", + ReferencedProjects = { project2 }, + }; + + var testAsset = _testAssets.CreateTestProject(project1); + + await VerifyEvaluation(testAsset, targetFramework: ToolsetInfo.CurrentTargetFramework, + [ + new($"Project3/obj/Debug/netstandard2.0/.NETStandard,Version=v2.0.AssemblyAttributes.cs", graphOnly: true), + new($"Project3/obj/Debug/netstandard2.0/Project3.AssemblyInfo.cs", graphOnly: true), + new("Project3/Project3.csproj", targetsOnly: true), + new("Project3/Project3.cs"), + new($"Project2/obj/Debug/netstandard2.0/.NETStandard,Version=v2.0.AssemblyAttributes.cs", graphOnly: true), + new($"Project2/obj/Debug/netstandard2.0/Project2.AssemblyInfo.cs", graphOnly: true), + new("Project2/Project2.csproj", targetsOnly: true), + new("Project2/Project2.cs"), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Project1.cs"), + ]); + } + + [Theory] + [CombinatorialData] + public async Task SingleTargetRoot_MultiTargetedDependency(bool specifyTargetFramework) + { + var project2 = new TestProject("Project2") + { + TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", + }; + + var project1 = new TestProject("Project1") + { + TargetFrameworks = ToolsetInfo.CurrentTargetFramework, + ReferencedProjects = { project2 }, + }; + + var testAsset = _testAssets.CreateTestProject(project1, identifier: specifyTargetFramework.ToString()); + + await VerifyEvaluation(testAsset, specifyTargetFramework ? ToolsetInfo.CurrentTargetFramework : null, + [ + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project1/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project1.AssemblyInfo.cs", graphOnly: true), + new("Project1/Project1.csproj", targetsOnly: true), + new("Project1/Project1.cs"), + new($"Project2/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"Project2/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/Project2.AssemblyInfo.cs", graphOnly: true), + new($"Project2/obj/Debug/net462/.NETFramework,Version=v4.6.2.AssemblyAttributes.cs", graphOnly: true), + new($"Project2/obj/Debug/net462/Project2.AssemblyInfo.cs", graphOnly: true), + new("Project2/Project2.csproj", targetsOnly: true), + new("Project2/Project2.cs"), + ]); + } + + [Fact] + public async Task FSharpProjectDependency() + { + var projectFS = new TestProject("FSProj") + { + TargetExtension = ".fsproj", + AdditionalItems = + { + new("Compile", new() { { "Include", "Lib.fs" } }) + }, + SourceFiles = + { + { "Lib.fs", "module Lib" } + } + }; + + var projectCS = new TestProject("CSProj") + { + ReferencedProjects = { projectFS }, + TargetExtension = ".csproj", + IsExe = true, + SourceFiles = + { + { "Program.cs", s_emptyProgram }, + }, + }; + + var testAsset = _testAssets.CreateTestProject(projectCS); + + await VerifyEvaluation(testAsset, + [ + new("CSProj/Program.cs"), + new($"CSProj/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"CSProj/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/CSProj.AssemblyInfo.cs", graphOnly: true), + new("CSProj/CSProj.csproj", targetsOnly: true), + new("FSProj/FSProj.fsproj", targetsOnly: true), + new("FSProj/Lib.fs"), + new($"FSProj/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.fs", graphOnly: true), + new($"FSProj/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/FSProj.AssemblyInfo.fs", graphOnly: true), + ]); + } + + [Fact] + public async Task VBProjectDependency() + { + var projectVB = new TestProject("VB") + { + TargetExtension = ".vbproj", + SourceFiles = + { + { "Lib.vb", """ + Class C + End Class + """ + } + } + }; + + var projectCS = new TestProject("CS") + { + ReferencedProjects = { projectVB }, + TargetExtension = ".csproj", + IsExe = true, + SourceFiles = + { + { "Program.cs", s_emptyProgram }, + }, + }; + + var testAsset = _testAssets.CreateTestProject(projectCS); + + await VerifyEvaluation(testAsset, + [ + new("CS/Program.cs"), + new($"CS/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.cs", graphOnly: true), + new($"CS/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/CS.AssemblyInfo.cs", graphOnly: true), + new("CS/CS.csproj", targetsOnly: true), + new("VB/VB.vbproj", targetsOnly: true), + new("VB/Lib.vb"), + new($"VB/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/{ToolsetInfo.CurrentTargetFrameworkMoniker}.AssemblyAttributes.vb", graphOnly: true), + new($"VB/obj/Debug/{ToolsetInfo.CurrentTargetFramework}/VB.AssemblyInfo.vb", graphOnly: true), + ]); + } + + [Fact] + public async Task ProjectReferences_Graph() + { + // A->B,F,W(Watch=False) + // B->C,E + // C->D + // D->E + // F->E,G + // G->E + // W->U + // Y->B,F,Z + var testDirectory = _testAssets.CopyTestAsset("ProjectReferences_Graph") + .WithSource() + .Path; + var projectA = Path.Combine(testDirectory, "A", "A.csproj"); + + var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory, muxerPath: MuxerPath); + var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); + var buildReporter = new BuildReporter(_reporter, options); + + var filesetFactory = new MSBuildFileSetFactory(projectA, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], processRunner, buildReporter); + + var result = await filesetFactory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); + Assert.NotNull(result); + + AssertEx.SequenceEqual( + [ + "A/A.cs: [A/A.csproj]", + "A/A.csproj: [A/A.csproj]", + "B/B.cs: [B/B.csproj]", + "B/B.csproj: [B/B.csproj]", + "C/C.cs: [C/C.csproj]", + "C/C.csproj: [C/C.csproj]", + "Common.cs: [A/A.csproj, G/G.csproj]", + "D/D.cs: [D/D.csproj]", + "D/D.csproj: [D/D.csproj]", + "E/E.cs: [E/E.csproj]", + "E/E.csproj: [E/E.csproj]", + "F/F.cs: [F/F.csproj]", + "F/F.csproj: [F/F.csproj]", + "G/G.cs: [G/G.csproj]", + "G/G.csproj: [G/G.csproj]", + ], Inspect(testDirectory, result.Files)); + + // ensure each project is only visited once for collecting watch items + AssertEx.SequenceEqual( + [ + "Collecting watch items from 'A'", + "Collecting watch items from 'B'", + "Collecting watch items from 'C'", + "Collecting watch items from 'D'", + "Collecting watch items from 'E'", + "Collecting watch items from 'F'", + "Collecting watch items from 'G'", + ], + _reporter.Messages.Where(l => l.text.Contains("Collecting watch items from")).Select(l => l.text.Trim()).Order()); + } + + [Fact] + public async Task MsbuildOutput() + { + var project2 = new TestProject("Project2") + { + TargetFrameworks = "netstandard2.1", + }; + + var project1 = new TestProject("Project1") + { + TargetFrameworks = "net462", + ReferencedProjects = { project2 }, + }; + + var testAsset = _testAssets.CreateTestProject(project1); + var project1Path = GetTestProjectPath(testAsset); + + var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!, muxerPath: MuxerPath); + var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); + var buildReporter = new BuildReporter(_reporter, options); + + var factory = new MSBuildFileSetFactory(project1Path, buildArguments: [], processRunner, buildReporter); + var result = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); + Assert.Null(result); + + // note: msbuild prints errors to stdout, we match the pattern and report as error: + AssertEx.Equal( + (MessageSeverity.Error, $"{project1Path} : error NU1201: Project Project2 is not compatible with net462 (.NETFramework,Version=v4.6.2). Project Project2 supports: netstandard2.1 (.NETStandard,Version=v2.1)"), + _reporter.Messages.Single(l => l.text.Contains("error NU1201"))); + } + + private readonly struct ExpectedFile(string path, string? staticAssetUrl = null, bool targetsOnly = false, bool graphOnly = false) + { + public string Path { get; } = path; + public string? StaticAssetUrl { get; } = staticAssetUrl; + public bool TargetsOnly { get; } = targetsOnly; + public bool GraphOnly { get; } = graphOnly; + } + + private Task VerifyEvaluation(TestAsset testAsset, ExpectedFile[] expectedFiles) + => VerifyEvaluation(testAsset, targetFramework: null, expectedFiles); + + private async Task VerifyEvaluation(TestAsset testAsset, string? targetFramework, ExpectedFile[] expectedFiles) + { + var testDir = testAsset.Path; + var rootProjectPath = GetTestProjectPath(testAsset); + + output.WriteLine("=== Evaluate using target ==="); + await VerifyTargetsEvaluation(); + + output.WriteLine("=== Evaluate using project graph ==="); + await VerifyProjectGraphEvaluation(); + + async Task VerifyTargetsEvaluation() + { + var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDir, muxerPath: MuxerPath) with { TestOutput = testDir }; + var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); + var buildArguments = targetFramework != null ? new[] { "/p:TargetFramework=" + targetFramework } : []; + var buildReporter = new BuildReporter(_reporter, options); + var factory = new MSBuildFileSetFactory(rootProjectPath, buildArguments, processRunner, buildReporter); + var targetsResult = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); + Assert.NotNull(targetsResult); + + var normalizedActual = Inspect(targetsResult.Files); + var normalizedExpected = expectedFiles.Where(f => !f.GraphOnly).Select(f => (f.Path, f.StaticAssetUrl)).OrderBy(f => f.Path); + AssertEx.SequenceEqual(normalizedExpected, normalizedActual); + } + + async Task VerifyProjectGraphEvaluation() + { + // Needs to be executed in dotnet-watch process in order for msbuild to load from the correct location. + + using var watchableApp = new WatchableApp(new DebugTestOutputLogger(output)); + var arguments = targetFramework != null ? new[] { "-f", targetFramework } : []; + watchableApp.Start(testAsset, arguments, relativeProjectDirectory: testAsset.TestProject!.Name); + await watchableApp.AssertOutputLineStartsWith(MessageDescriptor.WaitingForFileChangeBeforeRestarting, failure: _ => false); + + var normalizedActual = ParseOutput(watchableApp.Process.Output).OrderBy(f => f.relativePath); + var normalizedExpected = expectedFiles.Where(f => !f.TargetsOnly).Select(f => (f.Path, f.StaticAssetUrl)).OrderBy(f => f.Path); + + AssertEx.SequenceEqual(normalizedExpected, normalizedActual); + } + + string GetRelativePath(string fullPath) + => Path.GetRelativePath(testDir, fullPath).Replace('\\', '/'); + + IEnumerable<(string relativePath, string? staticAssetUrl)> Inspect(IReadOnlyDictionary files) + => files.Select(f => (relativePath: GetRelativePath(f.Key), staticAssetUrl: f.Value.StaticWebAssetPath)).OrderBy(f => f.relativePath); + + IEnumerable<(string relativePath, string? staticAssetUrl)> ParseOutput(IEnumerable output) + { + foreach (var line in output.SkipWhile(l => !Regex.IsMatch(l, "dotnet watch ⌚ Watching ([0-9]+) file[(]s[)] for changes")).Skip(1)) + { + var match = Regex.Match(line, $"> ([^{Path.PathSeparator}]*)({Path.PathSeparator}(.*))?"); + if (!match.Success) + { + break; + } + + yield return (GetRelativePath(match.Groups[1].Value), match.Groups[3].Value is { Length: > 0 } value ? value : null); + } + } + } + + private static string GetTestProjectPath(TestAsset projectAsset) + => Path.Combine(projectAsset.Path, projectAsset.TestProject!.Name!, projectAsset.TestProject!.Name + ".csproj"); + } +} diff --git a/test/dotnet-watch.Tests/Build/MsBuildFileSetFactoryTest.cs b/test/dotnet-watch.Tests/Build/MsBuildFileSetFactoryTest.cs deleted file mode 100644 index 0088f35d5db2..000000000000 --- a/test/dotnet-watch.Tests/Build/MsBuildFileSetFactoryTest.cs +++ /dev/null @@ -1,432 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Watch.UnitTests -{ - public class MsBuildFileSetFactoryTest(ITestOutputHelper output) - { - private readonly TestReporter _reporter = new(output); - private readonly TestAssetsManager _testAssets = new(output); - - private static string MuxerPath - => TestContext.Current.ToolsetUnderTest.DotNetHostPath; - - private static string InspectPath(string path, string rootDir) - => path.Substring(rootDir.Length + 1).Replace("\\", "/"); - - private static IEnumerable Inspect(string rootDir, IReadOnlyDictionary files) - => files - .OrderBy(entry => entry.Key) - .Select(entry => $"{InspectPath(entry.Key, rootDir)}: [{string.Join(", ", entry.Value.ContainingProjectPaths.Select(p => InspectPath(p, rootDir)))}]"); - - [Fact] - public async Task FindsCustomWatchItems() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = ToolsetInfo.CurrentTargetFramework, - }); - - project.WithProjectChanges(d => d.Root!.Add(XElement.Parse( -@" - -"))); - - WriteFile(project, "Program.cs"); - WriteFile(project, "app.js"); - WriteFile(project, "gulpfile.js"); - - var result = await Evaluate(project); - - AssertEx.EqualFileList( - GetTestProjectDirectory(project), - new[] - { - "Project1.csproj", - "Project1.cs", - "Program.cs", - "app.js" - }, - result.Files.Keys - ); - } - - [Fact] - public async Task ExcludesDefaultItemsWithWatchFalseMetadata() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = "net40", - AdditionalProperties = - { - ["EnableDefaultEmbeddedResourceItems"] = "false", - }, - }); - - project.WithProjectChanges(d => d.Root!.Add(XElement.Parse( -@" - -"))); - - WriteFile(project, "Program.cs"); - WriteFile(project, "Strings.resx"); - - var result = await Evaluate(project); - - AssertEx.EqualFileList( - GetTestProjectDirectory(project), - new[] - { - "Project1.csproj", - "Project1.cs", - "Program.cs", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task SingleTfm() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = ToolsetInfo.CurrentTargetFramework, - AdditionalProperties = - { - ["BaseIntermediateOutputPath"] = "obj", - }, - }); - - WriteFile(project, "Program.cs"); - WriteFile(project, "Class1.cs"); - WriteFile(project, Path.Combine("obj", "Class1.cs")); - WriteFile(project, Path.Combine("Properties", "Strings.resx")); - - var result = await Evaluate(project); - - AssertEx.EqualFileList( - GetTestProjectDirectory(project), - new[] - { - "Project1.csproj", - "Project1.cs", - "Program.cs", - "Class1.cs", - "Properties/Strings.resx", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task MultiTfm() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", - AdditionalProperties = - { - ["EnableDefaultCompileItems"] = "false", - }, - }); - - project.WithProjectChanges(d => d.Root!.Add(XElement.Parse( -$@" - - -"))); - - WriteFile(project, "Class1.netcore.cs"); - WriteFile(project, "Class1.desktop.cs"); - WriteFile(project, "Class1.notincluded.cs"); - - var result = await Evaluate(project); - - AssertEx.EqualFileList( - GetTestProjectDirectory(project), - new[] - { - "Project1.csproj", - "Class1.netcore.cs", - "Class1.desktop.cs", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task IncludesContentFiles() - { - var testDir = _testAssets.CreateTestDirectory(); - - var project = WriteFile(testDir, Path.Combine("Project1.csproj"), -@" - - netstandard2.1 - -"); - WriteFile(testDir, Path.Combine("Program.cs")); - - WriteFile(testDir, Path.Combine("wwwroot", "css", "app.css")); - WriteFile(testDir, Path.Combine("wwwroot", "js", "site.js")); - WriteFile(testDir, Path.Combine("wwwroot", "favicon.ico")); - - var result = await Evaluate(project); - - AssertEx.EqualFileList( - testDir.Path, - new[] - { - "Project1.csproj", - "Program.cs", - "wwwroot/css/app.css", - "wwwroot/js/site.js", - "wwwroot/favicon.ico", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task IncludesContentFilesFromRCL() - { - var testDir = _testAssets.CreateTestDirectory(); - WriteFile( - testDir, - Path.Combine("RCL1", "RCL1.csproj"), - $""" - - - netstandard2.1 - - - """); - - WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "css", "app.css")); - WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "js", "site.js")); - WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "favicon.ico")); - - var projectPath = WriteFile( - testDir, - Path.Combine("Project1", "Project1.csproj"), - """ - - - netstandard2.1 - - - - - - """); - - WriteFile(testDir, Path.Combine("Project1", "Program.cs")); - - var result = await Evaluate(projectPath); - - AssertEx.EqualFileList( - testDir.Path, - new[] - { - "Project1/Project1.csproj", - "Project1/Program.cs", - "RCL1/RCL1.csproj", - "RCL1/wwwroot/css/app.css", - "RCL1/wwwroot/js/site.js", - "RCL1/wwwroot/favicon.ico", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task ProjectReferences_OneLevel() - { - var project2 = _testAssets.CreateTestProject(new TestProject("Project2") - { - TargetFrameworks = "netstandard2.0", - }); - - var project1 = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", - ReferencedProjects = { project2.TestProject, }, - }); - - var result = await Evaluate(project1); - - AssertEx.EqualFileList( - project1.TestRoot, - new[] - { - "Project2/Project2.csproj", - "Project2/Project2.cs", - "Project1/Project1.csproj", - "Project1/Project1.cs", - }, - result.Files.Keys - ); - } - - [Fact] - public async Task TransitiveProjectReferences_TwoLevels() - { - var project3 = _testAssets.CreateTestProject(new TestProject("Project3") - { - TargetFrameworks = "netstandard2.0", - }); - - var project2 = _testAssets.CreateTestProject(new TestProject("Project2") - { - TargetFrameworks = "netstandard2.0", - ReferencedProjects = { project3.TestProject }, - }); - - var project1 = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", - ReferencedProjects = { project2.TestProject }, - }); - - var result = await Evaluate(project1); - - AssertEx.EqualFileList( - project1.TestRoot, - new[] - { - "Project3/Project3.csproj", - "Project3/Project3.cs", - "Project2/Project2.csproj", - "Project2/Project2.cs", - "Project1/Project1.csproj", - "Project1/Project1.cs", - }, - result.Files.Keys - ); - - Assert.All(result.Files.Values, f => Assert.False(f.IsStaticFile, $"File {f.FilePath} should not be a static file.")); - } - - [Fact] - public async Task ProjectReferences_Graph() - { - // A->B,F,W(Watch=False) - // B->C,E - // C->D - // D->E - // F->E,G - // G->E - // W->U - // Y->B,F,Z - var testDirectory = _testAssets.CopyTestAsset("ProjectReferences_Graph") - .WithSource() - .Path; - var projectA = Path.Combine(testDirectory, "A", "A.csproj"); - - var options = TestOptions.GetEnvironmentOptions(workingDirectory: testDirectory, muxerPath: MuxerPath); - var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); - - var filesetFactory = new MSBuildFileSetFactory(projectA, buildArguments: ["/p:_DotNetWatchTraceOutput=true"], options, processRunner, _reporter); - - var result = await filesetFactory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); - Assert.NotNull(result); - - AssertEx.SequenceEqual( - [ - "A/A.cs: [A/A.csproj]", - "A/A.csproj: [A/A.csproj]", - "B/B.cs: [B/B.csproj]", - "B/B.csproj: [B/B.csproj]", - "C/C.cs: [C/C.csproj]", - "C/C.csproj: [C/C.csproj]", - "Common.cs: [A/A.csproj, G/G.csproj]", - "D/D.cs: [D/D.csproj]", - "D/D.csproj: [D/D.csproj]", - "E/E.cs: [E/E.csproj]", - "E/E.csproj: [E/E.csproj]", - "F/F.cs: [F/F.csproj]", - "F/F.csproj: [F/F.csproj]", - "G/G.cs: [G/G.csproj]", - "G/G.csproj: [G/G.csproj]", - ], Inspect(testDirectory, result.Files)); - - // ensure each project is only visited once for collecting watch items - AssertEx.SequenceEqual( - [ - "Collecting watch items from 'A'", - "Collecting watch items from 'B'", - "Collecting watch items from 'C'", - "Collecting watch items from 'D'", - "Collecting watch items from 'E'", - "Collecting watch items from 'F'", - "Collecting watch items from 'G'", - ], - _reporter.Messages.Where(l => l.text.Contains("Collecting watch items from")).Select(l => l.text.Trim()).Order()); - } - - [Fact] - public async Task MsbuildOutput() - { - var project2 = _testAssets.CreateTestProject(new TestProject("Project2") - { - TargetFrameworks = "netstandard2.1", - }); - - var project1 = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = $"net462", - ReferencedProjects = { project2.TestProject, }, - }); - - var project1Path = GetTestProjectPath(project1); - - var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(project1Path)!, muxerPath: MuxerPath); - var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); - - var factory = new MSBuildFileSetFactory(project1Path, buildArguments: [], options, processRunner, _reporter); - var result = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); - Assert.Null(result); - - // note: msbuild prints errors to stdout, we match the pattern and report as error: - AssertEx.Equal( - (MessageSeverity.Error, $"{project1Path} : error NU1201: Project Project2 is not compatible with net462 (.NETFramework,Version=v4.6.2). Project Project2 supports: netstandard2.1 (.NETStandard,Version=v2.1)"), - _reporter.Messages.Single(l => l.text.Contains("error NU1201"))); - } - - private Task Evaluate(TestAsset projectPath) - => Evaluate(GetTestProjectPath(projectPath)); - - private async Task Evaluate(string projectPath) - { - var options = TestOptions.GetEnvironmentOptions(workingDirectory: Path.GetDirectoryName(projectPath)!, muxerPath: MuxerPath); - var processRunner = new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None); - var factory = new MSBuildFileSetFactory(projectPath, buildArguments: [], options, processRunner, _reporter); - var result = await factory.TryCreateAsync(requireProjectGraph: null, CancellationToken.None); - Assert.NotNull(result); - return result; - } - - private static string GetTestProjectPath(TestAsset target) => Path.Combine(GetTestProjectDirectory(target), target.TestProject?.Name + ".csproj"); - - private static string WriteFile(TestAsset testAsset, string name, string contents = "") - { - var path = Path.Combine(GetTestProjectDirectory(testAsset), name); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, contents); - - return path; - } - - private static string WriteFile(TestDirectory testAsset, string name, string contents = "") - { - var path = Path.Combine(testAsset.Path, name); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, contents); - - return path; - } - - private static string GetTestProjectDirectory(TestAsset testAsset) - => Path.Combine(testAsset.Path, testAsset.TestProject?.Name ?? string.Empty); - } -} diff --git a/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs b/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs index 980a19373dcb..2da6526e5bf2 100644 --- a/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs +++ b/test/dotnet-watch.Tests/FileWatcher/FileWatcherTests.cs @@ -70,7 +70,8 @@ private async Task TestOperation( AssertEx.SequenceEqual(expectedChanges, filesChanged.OrderBy(x => x.Path)); } - private sealed class TestFileWatcher(IReporter reporter) : FileWatcher(reporter) + private sealed class TestFileWatcher(IReporter reporter) + : FileWatcher(reporter, TestOptions.GetEnvironmentOptions()) { public IReadOnlyDictionary DirectoryTreeWatchers => _directoryTreeWatchers; public IReadOnlyDictionary DirectoryWatchers => _directoryWatchers; diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index d366a921bc6e..95c258b65945 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -310,7 +310,7 @@ public async Task DefaultItemExcludes_DefaultItemsDisabled() await App.WaitUntilOutputContains($"dotnet watch ⌚ Ignoring change in output directory: Add '{objDirFilePath}'"); } - [Fact(Skip = "https://github.com/dotnet/sdk/issues/49542")] + [Fact] public async Task ProjectChange_GlobalUsings() { var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") @@ -340,9 +340,9 @@ public async Task ProjectChange_GlobalUsings() """)); - await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, $"WatchNoDepsApp ({ToolsetInfo.CurrentTargetFramework})"); + await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})", failure: _ => false); - await App.WaitUntilOutputContains(">>> System.Linq.Enumerable"); + await App.WaitUntilOutputContains(">>> System.Xml.Linq.XDocument"); App.AssertOutputContains(MessageDescriptor.ReEvaluationCompleted); } @@ -380,7 +380,7 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive) await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false); App.AssertOutputContains("⌚ Restart is needed to apply the changes"); - App.AssertOutputContains($"⌚ [auto-restart] {programPath}(33,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); + App.AssertOutputContains($"⌚ [auto-restart] {programPath}(38,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); App.Process.ClearOutput(); @@ -410,7 +410,7 @@ public async Task AutoRestartOnRudeEditAfterRestartPrompt() await App.AssertOutputLineStartsWith(" ❔ Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)", failure: _ => false); App.AssertOutputContains("⌚ Restart is needed to apply the changes."); - App.AssertOutputContains($"❌ {programPath}(33,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); + App.AssertOutputContains($"❌ {programPath}(38,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application."); App.Process.ClearOutput(); App.SendKey('a'); @@ -427,7 +427,7 @@ public async Task AutoRestartOnRudeEditAfterRestartPrompt() await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false); App.AssertOutputContains("⌚ Restart is needed to apply the changes"); - App.AssertOutputContains($"⌚ [auto-restart] {programPath}(33,1): error ENC0033: Deleting method 'F()' requires restarting the application."); + App.AssertOutputContains($"⌚ [auto-restart] {programPath}(38,1): error ENC0033: Deleting method 'F()' requires restarting the application."); App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited"); App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched"); } @@ -1009,7 +1009,7 @@ public static void PrintDirectoryName([CallerFilePathAttribute] string filePath Directory.CreateDirectory(oldSubdir); File.WriteAllText(Path.Combine(oldSubdir, "Foo.cs"), source); - App.Start(testAsset, [], "AppWithDeps"); + App.Start(testAsset, ["--non-interactive"], "AppWithDeps"); await App.AssertWaitingForChanges(); @@ -1027,7 +1027,10 @@ public static void PrintDirectoryName([CallerFilePathAttribute] string filePath Log($"Renamed '{oldSubdir}' to '{newSubdir}'."); - await App.AssertOutputLineStartsWith("> NewSubdir"); + // dotnet-watch may observe the delete separately from the new file write. + // If so, rude edit is reported, the app is auto-restarted and we should observe the final result. + + await App.AssertOutputLineStartsWith("> NewSubdir", failure: _ => false); } [PlatformSpecificFact(TestPlatforms.Windows)] // "https://github.com/dotnet/sdk/issues/49307") diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 615d62766238..f2759b7a7349 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -22,14 +22,7 @@ public async Task ReferenceOutputAssembly_False() var processRunner = new ProcessRunner(environmentOptions.ProcessCleanupTimeout, CancellationToken.None); - var factory = new MSBuildFileSetFactory( - rootProjectFile: options.ProjectPath, - buildArguments: [], - environmentOptions: environmentOptions, - processRunner, - reporter); - - var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false, CancellationToken.None); + var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(options.ProjectPath, globalOptions: [], reporter, projectGraphRequired: false, CancellationToken.None); var handler = new CompilationHandler(reporter, processRunner); await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 50f4bdb8687f..e0989c49bc0b 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -1,11 +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.Collections.Immutable; using System.Runtime.CompilerServices; namespace Microsoft.DotNet.Watch.UnitTests; +[Collection(nameof(InProcBuildTestCollection))] public class RuntimeProcessLauncherTests(ITestOutputHelper logger) : DotNetWatchTestBase(logger) { public enum TriggerEvent @@ -636,4 +636,50 @@ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind throw new InvalidOperationException(); } } + + [Fact] + public async Task ProjectAndSourceFileChange() + { + var testAsset = CopyTestAsset("WatchHotReloadApp"); + + var workingDirectory = testAsset.Path; + var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + await using var w = StartWatcher(testAsset, [], workingDirectory, projectPath); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + + var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("System.Xml.Linq.XDocument")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + // let the host process start: + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(programPath, src => src.Replace("""Console.WriteLine(".");""", """Console.WriteLine(typeof(XDocument));""")); + UpdateSourceFile(projectPath, src => src.Replace("", """""")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for change handled ..."); + await changeHandled.WaitAsync(w.ShutdownSource.Token); + + Log("Waiting for output 'System.Xml.Linq.XDocument'..."); + await hasUpdatedOutput.Task; + } } diff --git a/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs b/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs new file mode 100644 index 000000000000..f0054a2e5945 --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/InProcBuildTestCollection.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch.UnitTests; + +/// +/// All tests that validate msbuild build in-proc must be included in this collection +/// as mutliple builds can't run in parallel in the same process. +/// +[CollectionDefinition(nameof(InProcBuildTestCollection), DisableParallelization = true)] +public sealed class InProcBuildTestCollection +{ +} diff --git a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs index 86e9e9968ba0..2c2df0ef1144 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs +++ b/test/dotnet-watch.Tests/TestUtilities/MockFileSetFactory.cs @@ -1,19 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - namespace Microsoft.DotNet.Watch.UnitTests; internal class MockFileSetFactory() : MSBuildFileSetFactory( rootProjectFile: "test.csproj", buildArguments: [], - TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options, - new ProcessRunner(options.ProcessCleanupTimeout, CancellationToken.None), - NullReporter.Singleton) + new ProcessRunner((TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet") is var options ? options : options).ProcessCleanupTimeout, CancellationToken.None), + new BuildReporter(NullReporter.Singleton, options)) { - public Func TryCreateImpl; + public Func? TryCreateImpl; - public override ValueTask TryCreateAsync(bool? requireProjectGraph, CancellationToken cancellationToken) + public override ValueTask TryCreateAsync(bool? requireProjectGraph, CancellationToken cancellationToken) => ValueTask.FromResult(TryCreateImpl?.Invoke()); } diff --git a/test/dotnet-watch.Tests/TestUtilities/MockReporter.cs b/test/dotnet-watch.Tests/TestUtilities/MockReporter.cs index 97ad1458a95c..5bd10b40fcf5 100644 --- a/test/dotnet-watch.Tests/TestUtilities/MockReporter.cs +++ b/test/dotnet-watch.Tests/TestUtilities/MockReporter.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Build.Graph; - namespace Microsoft.DotNet.Watch.UnitTests; internal class MockReporter : IReporter diff --git a/test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs b/test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs new file mode 100644 index 000000000000..1248a96695a4 --- /dev/null +++ b/test/dotnet-watch.Tests/TestUtilities/ModuleInitializer.cs @@ -0,0 +1,59 @@ +// 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; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using Microsoft.Build.Locator; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + // Ensure that we load the msbuild binaries from redist deployment. Otherwise, msbuild might use target files + // that do not match the implementations of the core tasks. + + // If this throws make sure the test assembly, or any of its dependencies copied to this project's output directory, + // does not have any public type that has a dependency on msbuild. + // xUnit loads all public types and any reference to msbuild assembly will trigger its load. + + var toolset = TestContext.Current.ToolsetUnderTest; + var sdkDir = toolset.SdkFolderUnderTest; + var watchDir = Path.Combine(sdkDir, "DotnetTools", "dotnet-watch", toolset.SdkVersion, "tools", ToolsetInfo.CurrentTargetFramework, "any"); + + MSBuildLocator.RegisterMSBuildPath(sdkDir); + + // The project references of this project are set up so that some dependencies are not copied to the output directory. + // These need to be resolved in one of the Redist directories. + + AssemblyLoadContext.Default.Resolving += (_, name) => + { + var path = GetPath(name, sdkDir); + if (!File.Exists(path)) + { + path = GetPath(name, watchDir); + if (!File.Exists(path)) + { + return null; + } + } + + return AssemblyLoadContext.Default.LoadFromAssemblyPath(path); + + static string GetPath(AssemblyName name, string dir) + { + var path = dir; + if (name.CultureName != null) + { + path = Path.Combine(path, name.CultureName); + } + + path = Path.Combine(path, name.Name + ".dll"); + return path; + } + }; + } +} diff --git a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs index 020a7597ec2a..20362ea5b413 100644 --- a/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs +++ b/test/dotnet-watch.Tests/TestUtilities/TestOptions.cs @@ -17,7 +17,7 @@ public static EnvironmentOptions GetEnvironmentOptions(string workingDirectory = // 0 timeout for process cleanup in tests. We can't send Ctrl+C on Windows, so process termination must be forced. var processCleanupTimeout = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? TimeSpan.FromSeconds(0) : TimeSpan.FromSeconds(1); - return new(workingDirectory, muxerPath, processCleanupTimeout, TestFlags: TestFlags.RunningAsTest, TestOutput: asset != null ? GetWatchTestOutputPath(asset) : ""); + return new(workingDirectory, muxerPath, processCleanupTimeout, IsPollingEnabled: true, TestFlags: TestFlags.RunningAsTest, TestOutput: asset != null ? GetWatchTestOutputPath(asset) : ""); } public static CommandLineOptions GetCommandLineOptions(string[] args) diff --git a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs index dcbbc27b6073..76f26275b2ae 100644 --- a/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/TestUtilities/WatchableApp.cs @@ -113,8 +113,10 @@ public Task AssertFileChanged() public Task AssertExiting() => AssertOutputLineStartsWith(ExitingMessage); - public void Start(TestAsset asset, IEnumerable arguments, string relativeProjectDirectory = null, string workingDirectory = null, TestFlags testFlags = TestFlags.RunningAsTest) + public void Start(TestAsset asset, IEnumerable arguments, string relativeProjectDirectory = null, string workingDirectory = null, TestFlags testFlags = TestFlags.None) { + testFlags |= TestFlags.RunningAsTest; + var projectDirectory = (relativeProjectDirectory != null) ? Path.Combine(asset.Path, relativeProjectDirectory) : asset.Path; var commandSpec = new DotnetCommand(Logger, ["watch", .. DotnetWatchArgs, .. arguments]) diff --git a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs index 33db911fe985..72bc322c57ff 100644 --- a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs +++ b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Watch.UnitTests { public partial class BuildEvaluatorTests { - private static readonly EvaluationResult s_emptyEvaluationResult = new(new Dictionary(), projectGraph: null); + private static readonly MSBuildFileSetFactory.EvaluationResult s_emptyEvaluationResult = new(new Dictionary(), projectGraph: null); private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementalism = false) { @@ -86,7 +86,7 @@ public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIs // There's a chance that the watcher does not correctly report edits to msbuild files on // concurrent edits. MSBuildEvaluationFilter uses timestamps to additionally track changes to these files. - var result = new EvaluationResult( + var result = new MSBuildFileSetFactory.EvaluationResult( new Dictionary() { { "Controlller.cs", new FileItem { FilePath = "Controlller.cs", ContainingProjectPaths = []} }, diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 0bd6266b9abf..41b346c0a555 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -10,10 +10,15 @@ - - + + + + diff --git a/test/dotnet.Tests/CommandTests/Restore/GivenThatIWantToRestoreApp.cs b/test/dotnet.Tests/CommandTests/Restore/GivenThatIWantToRestoreApp.cs index 66699f4bfd89..22e2f89acb30 100644 --- a/test/dotnet.Tests/CommandTests/Restore/GivenThatIWantToRestoreApp.cs +++ b/test/dotnet.Tests/CommandTests/Restore/GivenThatIWantToRestoreApp.cs @@ -53,6 +53,7 @@ public void ItRestoresLibToSpecificDirectory(bool useStaticGraphEvaluation, stri { Name = "RestoreToDir", TargetFrameworks = ToolsetInfo.CurrentTargetFramework, + TargetExtension = extension, }; testProject.PackageReferences.Add(new TestPackageReference("Newtonsoft.Json", ToolsetInfo.GetNewtonsoftJsonPackageVersion())); @@ -61,7 +62,7 @@ public void ItRestoresLibToSpecificDirectory(bool useStaticGraphEvaluation, stri testProject.PackageReferences.Add(new TestPackageReference("FSharp.Core", "6.0.1", updatePackageReference: true)); } - var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: useStaticGraphEvaluation.ToString() + extension, targetExtension: extension); + var testAsset = _testAssetsManager.CreateTestProject(testProject, identifier: useStaticGraphEvaluation.ToString() + extension); var rootPath = Path.Combine(testAsset.TestRoot, testProject.Name);