diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs index 54b265c2350f..6848047eba4f 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs @@ -16,1024 +16,524 @@ namespace Microsoft.DotNet.Cli.Run.Tests; public sealed class RunFileTests_CscOnlyAndApi(ITestOutputHelper log) : RunFileTestBase(log) { - - /// - /// Verifies that msbuild-based runs use CSC args equivalent to csc-only runs. - /// Can regenerate CSC arguments template in . - /// [Fact] - public void CscArguments() + public void UpToDate() { var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - const string programName = "TestProgram"; - const string fileName = $"{programName}.cs"; - string entryPointPath = Path.Join(testInstance.Path, fileName); - File.WriteAllText(entryPointPath, s_program); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine("Hello v1"); + """); // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(entryPointPath); + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(Path.Join(testInstance.Path, "Program.cs")); if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - // Build using MSBuild. - new DotnetCommand(Log, "run", fileName, "-bl", "--no-cache") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut($"Hello from {programName}"); + Build(testInstance, BuildLevel.Csc, expectedOutput: "Hello v1"); - // Find the csc args used by the build. - var msbuildCall = FindCompilerCall(Path.Join(testInstance.Path, "msbuild.binlog")); - var msbuildCallArgs = msbuildCall.GetArguments(); - var msbuildCallArgsString = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(msbuildCallArgs); + Build(testInstance, BuildLevel.None, expectedOutput: "Hello v1"); - // Generate argument template code. - string sdkPath = NormalizePath(SdkTestContext.Current.ToolsetUnderTest.SdkFolderUnderTest); - string dotNetRootPath = NormalizePath(SdkTestContext.Current.ToolsetUnderTest.DotNetRoot); - string nuGetCachePath = NormalizePath(SdkTestContext.Current.NuGetCachePath!); - string artifactsDirNormalized = NormalizePath(artifactsDir); - string objPath = $"{artifactsDirNormalized}/obj/debug"; - string entryPointPathNormalized = NormalizePath(entryPointPath); - var msbuildArgsToVerify = new List(); - var nuGetPackageFilePaths = new List(); - bool referenceSpreadInserted = false; - bool analyzerSpreadInserted = false; - const string NetCoreAppRefPackPath = "packs/Microsoft.NETCore.App.Ref/"; - var code = new StringBuilder(); - code.AppendLine($$""" - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. + Build(testInstance, BuildLevel.None, expectedOutput: "Hello v1"); - using System.Text.Json; + // Change the source file (a rebuild is necessary). + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); - namespace Microsoft.DotNet.Cli.Commands.Run; + Build(testInstance, BuildLevel.Csc); - // Generated by test `{{nameof(RunFileTests_CscOnlyAndApi)}}.{{nameof(CscArguments)}}`. - partial class CSharpCompilerCommand - { - private IEnumerable GetCscArguments( - string objDir, - string binDir) - { - return - [ - """); - foreach (var arg in msbuildCallArgs) - { - // This option needs to be passed on the command line, not in an RSP file. - if (arg is "/noconfig") - { - continue; - } + Build(testInstance, BuildLevel.None); - // We don't need to generate a ref assembly. - if (arg.StartsWith("/refout:", StringComparison.Ordinal)) - { - continue; - } + // Change an unrelated source file (no rebuild necessary). + File.WriteAllText(Path.Join(testInstance.Path, "Program2.cs"), "test"); - // There should be no source link arguments. - if (arg.StartsWith("/sourcelink:", StringComparison.Ordinal)) - { - Assert.Fail($"Unexpected source link argument: {arg}"); - } + Build(testInstance, BuildLevel.None); - // PreferredUILang is normally not set by default but can be in builds, so ignore it. - if (arg.StartsWith("/preferreduilang:", StringComparison.Ordinal)) - { - continue; - } + // Add an implicit build file (a rebuild is necessary). + string buildPropsFile = Path.Join(testInstance.Path, "Directory.Build.props"); + File.WriteAllText(buildPropsFile, """ + + + $(DefineConstants);CUSTOM_DEFINE + + + """); - bool needsInterpolation = false; - bool fromNuGetPackage = false; + Build(testInstance, BuildLevel.All, expectedOutput: """ + Hello from Program + Custom define + """); - // Normalize slashes in paths. - string rewritten = NormalizePathArg(arg); + Build(testInstance, BuildLevel.None, expectedOutput: """ + Hello from Program + Custom define + """); - // Remove quotes. - rewritten = RemoveQuotes(rewritten); + // Change the implicit build file (a rebuild is necessary). + string importedFile = Path.Join(testInstance.Path, "Settings.props"); + File.WriteAllText(importedFile, """ + + + """); + File.WriteAllText(buildPropsFile, """ + + + + """); - string msbuildArgToVerify = rewritten; + Build(testInstance, BuildLevel.All); - // Use variable SDK path. - if (rewritten.Contains(sdkPath, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(sdkPath, "{SdkPath}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + // Change the imported build file (this is not recognized). + File.WriteAllText(importedFile, """ + + + $(DefineConstants);CUSTOM_DEFINE + + + """); - // Use variable .NET root path. - if (rewritten.Contains(dotNetRootPath, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(dotNetRootPath, "{DotNetRootPath}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + Build(testInstance, BuildLevel.None); - // Use variable NuGet cache path. - if (rewritten.Contains(nuGetCachePath, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(nuGetCachePath, "{NuGetCachePath}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - fromNuGetPackage = true; - } + // Force rebuild. + Build(testInstance, BuildLevel.All, args: ["--no-cache"], expectedOutput: """ + Hello from Program + Custom define + """); - // Use variable intermediate dir path. - if (rewritten.Contains(objPath, StringComparison.OrdinalIgnoreCase)) - { - // We want to emit the resulting DLL directly into the bin folder. - bool isOut = arg.StartsWith("/out", StringComparison.Ordinal); - string replacement = isOut ? "{binDir}" : "{objDir}"; + // Remove an implicit build file (a rebuild is necessary). + File.Delete(buildPropsFile); + Build(testInstance, BuildLevel.Csc); - if (isOut) - { - msbuildArgToVerify = msbuildArgToVerify.Replace("/obj/", "/bin/", StringComparison.OrdinalIgnoreCase); - } + // Force rebuild. + Build(testInstance, BuildLevel.All, args: ["--no-cache"]); - rewritten = rewritten.Replace(objPath, replacement, StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + Build(testInstance, BuildLevel.None); - // Use variable file path. - if (rewritten.Contains(entryPointPathNormalized, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(entryPointPathNormalized, "{" + nameof(CSharpCompilerCommand.EntryPointFileFullPath) + "}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + // Pass argument (no rebuild necessary). + Build(testInstance, BuildLevel.None, args: ["--", "test-arg"], expectedOutput: """ + echo args:test-arg + Hello from Program + """); - // Use variable file name. - if (rewritten.Contains(fileName, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(fileName, "{FileName}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + // Change config (a rebuild is necessary). + Build(testInstance, BuildLevel.All, args: ["-c", "Release"], expectedOutput: """ + Hello from Program + Release config + """); - // Use variable program name. - if (rewritten.Contains(programName, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(programName, "{FileNameWithoutExtension}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + // Keep changed config (no rebuild necessary). + Build(testInstance, BuildLevel.None, args: ["-c", "Release"], expectedOutput: """ + Hello from Program + Release config + """); - // Use variable runtime version. - if (rewritten.Contains(CSharpCompilerCommand.RuntimeVersion, StringComparison.OrdinalIgnoreCase)) - { - rewritten = rewritten.Replace(CSharpCompilerCommand.RuntimeVersion, "{" + nameof(CSharpCompilerCommand.RuntimeVersion) + "}", StringComparison.OrdinalIgnoreCase); - needsInterpolation = true; - } + // Change config back (a rebuild is necessary). + Build(testInstance, BuildLevel.Csc); - // Ignore `/analyzerconfig` which is not variable (so it comes from the machine or sdk repo). - if (!needsInterpolation && arg.StartsWith("/analyzerconfig", StringComparison.Ordinal)) - { - continue; - } + // Build with a failure. + new DotnetCommand(Log, ["run", "Program.cs", "-p:LangVersion=Invalid"]) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining("error CS1617"); // Invalid option 'Invalid' for /langversion. - // Use GetFrameworkReferenceArguments() for framework references instead of hard-coding them. - if (arg.StartsWith("/reference:", StringComparison.Ordinal)) - { - if (!referenceSpreadInserted) - { - code.AppendLine(""" - .. GetFrameworkReferenceArguments(), - """); - referenceSpreadInserted = true; - } + // A rebuild is necessary since the last build failed. + Build(testInstance, BuildLevel.Csc); + } - msbuildArgsToVerify.Add(msbuildArgToVerify); - continue; - } + [Fact] + public void UpToDate_InvalidOptions() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); - // Use GetFrameworkAnalyzerArguments() for targeting-pack analyzers instead of hard-coding them. - if (arg.StartsWith("/analyzer:", StringComparison.Ordinal) - && rewritten.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)) - { - if (!analyzerSpreadInserted) - { - code.AppendLine(""" - .. GetFrameworkAnalyzerArguments(), - """); - analyzerSpreadInserted = true; - } - - msbuildArgsToVerify.Add(msbuildArgToVerify); - continue; - } + new DotnetCommand(Log, "run", "Program.cs", "--no-cache", "--no-build") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.CannotCombineOptions, "--no-cache", "--no-build")); + } - string prefix = needsInterpolation ? "$" : string.Empty; + /// + /// optimization should see through symlinks. + /// See . + /// + [Fact] + public void UpToDate_SymbolicLink() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); - code.AppendLine($""" - {prefix}"{rewritten}", - """); + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); - msbuildArgsToVerify.Add(msbuildArgToVerify); + var programFileName = "linked"; + var programPath = Path.Join(testInstance.Path, programFileName); - if (fromNuGetPackage) - { - nuGetPackageFilePaths.Add(CSharpCompilerCommand.IsPathOption(rewritten, out int colonIndex) - ? rewritten.Substring(colonIndex + 1) - : rewritten); - } - } - code.AppendLine(""" - ]; - } + File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); - /// - /// Files that come from referenced NuGet packages (e.g., analyzers for NativeAOT) need to be checked specially (if they don't exist, MSBuild needs to run). - /// - public static IEnumerable GetPathsOfCscInputsFromNuGetCache() - { - return - [ - """); - foreach (var nuGetPackageFilePath in nuGetPackageFilePaths) - { - code.AppendLine($""" - $"{nuGetPackageFilePath}", - """); - } - code.AppendLine(""" - ]; - } - """); + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - // Generate file content templates. - var baseDirectory = TestPathUtility.ResolveTempPrefixLink(Path.GetDirectoryName(entryPointPath)!); - var replacements = new List<(string, string)> - { - (TestPathUtility.ResolveTempPrefixLink(entryPointPath), nameof(CSharpCompilerCommand.EntryPointFileFullPath)), - (baseDirectory + Path.DirectorySeparatorChar, nameof(CSharpCompilerCommand.BaseDirectoryWithTrailingSeparator)), - (baseDirectory, nameof(CSharpCompilerCommand.BaseDirectory)), - (programName, nameof(CSharpCompilerCommand.FileNameWithoutExtension)), - (CSharpCompilerCommand.TargetFrameworkVersion, nameof(CSharpCompilerCommand.TargetFrameworkVersion)), - (CSharpCompilerCommand.TargetFramework, nameof(CSharpCompilerCommand.TargetFramework)), - (CSharpCompilerCommand.DefaultRuntimeVersion, nameof(CSharpCompilerCommand.DefaultRuntimeVersion)), - }; - var emittedFiles = Directory.EnumerateFiles(artifactsDir, "*", SearchOption.AllDirectories).Order(); - foreach (var emittedFile in emittedFiles) - { - var emittedFileName = Path.GetFileName(emittedFile); - var generatedMethodName = GetGeneratedMethodName(emittedFileName); - if (generatedMethodName is null) - { - Log.WriteLine($"Skipping unrecognized file '{emittedFile}'."); - continue; - } + Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); - var emittedFileContent = File.ReadAllText(emittedFile); + Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); - string interpolatedString = emittedFileContent; - string interpolationPrefix; + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); - if (emittedFileName.EndsWith(".json", StringComparison.Ordinal)) - { - interpolationPrefix = "$$"; - foreach (var (key, value) in replacements) - { - interpolatedString = interpolatedString.Replace(JsonSerializer.Serialize(key), "{{JsonSerializer.Serialize(" + value + ")}}"); - } - } - else - { - interpolationPrefix = "$"; - foreach (var (key, value) in replacements) - { - interpolatedString = interpolatedString.Replace(key, "{" + value + "}"); - } - } + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } - if (interpolatedString == emittedFileContent) - { - interpolationPrefix = ""; - } + /// + /// Similar to but with a chain of symlinks. + /// + [Fact] + public void UpToDate_SymbolicLink2() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); - code.AppendLine($$"""" + var originalPath = Path.Join(testInstance.Path, "original.cs"); + var code = """ + #!/usr/bin/env dotnet + Console.WriteLine("v1"); + """; + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + File.WriteAllText(originalPath, code, utf8NoBom); - private string Get{{generatedMethodName}}Content() - { - return {{interpolationPrefix}}""" - {{interpolatedString}} - """; - } - """"); - } + var intermediateFileName = "linked1"; + var intermediatePath = Path.Join(testInstance.Path, intermediateFileName); - code.AppendLine(""" - } - """); + File.CreateSymbolicLink(path: intermediatePath, pathToTarget: originalPath); - // Save the code. - var codeFolder = new DirectoryInfo(Path.Join( - SdkTestContext.Current.ToolsetUnderTest.RepoRoot, - "src", "Cli", "dotnet", "Commands", "Run")); - var nonGeneratedFile = codeFolder.File("CSharpCompilerCommand.cs"); - if (!nonGeneratedFile.Exists) - { - Log.WriteLine($"Skipping code generation because file does not exist: {nonGeneratedFile.FullName}"); - } - else - { - var codeFilePath = codeFolder.File("CSharpCompilerCommand.Generated.cs"); - var existingText = codeFilePath.Exists ? File.ReadAllText(codeFilePath.FullName) : string.Empty; - var newText = code.ToString(); - if (existingText != newText) - { - Log.WriteLine($"{codeFilePath.FullName} needs to be updated:"); - Log.WriteLine(newText); - if (Env.GetEnvironmentVariableAsBool("CI")) - { - throw new InvalidOperationException($"Not updating file in CI: {codeFilePath.FullName}"); - } - else - { - File.WriteAllText(codeFilePath.FullName, newText); - throw new InvalidOperationException($"File outdated, commit the changes: {codeFilePath.FullName}"); - } - } - } + var programFileName = "linked2"; + var programPath = Path.Join(testInstance.Path, programFileName); - // Build using CSC. - Directory.Delete(artifactsDir, recursive: true); - new DotnetCommand(Log, "run", fileName, "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut($""" - {CliCommandStrings.NoBinaryLogBecauseRunningJustCsc} - Hello from {programName} - """); + File.CreateSymbolicLink(path: programPath, pathToTarget: intermediatePath); - // Read args from csc.rsp file. - var rspFilePath = Path.Join(artifactsDir, "csc.rsp"); - var cscOnlyCallArgs = File.ReadAllLines(rspFilePath); - var cscOnlyCallArgsString = string.Join(' ', cscOnlyCallArgs); + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - // Check that csc args between MSBuild run and CSC-only run are equivalent. - var normalizedCscOnlyArgs = cscOnlyCallArgs - .Select(static a => NormalizePathArg(RemoveQuotes(a))) - .ToList(); - Log.WriteLine("CSC-only args:"); - Log.WriteLine(string.Join(Environment.NewLine, normalizedCscOnlyArgs)); - Log.WriteLine("MSBuild args:"); - Log.WriteLine(string.Join(Environment.NewLine, msbuildArgsToVerify)); + Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); - // References and targeting-pack analyzers may be in a different order (FrameworkList.xml vs. MSBuild), - // so compare them as sets. All other args must be in the same order. - var cscOnlyRefArgs = normalizedCscOnlyArgs.Where(static a => a.StartsWith("/reference:", StringComparison.Ordinal)).ToList(); - var cscOnlyAnalyzerArgs = normalizedCscOnlyArgs.Where(a => a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)).ToList(); - var cscOnlyOtherArgs = normalizedCscOnlyArgs.Where(a => !a.StartsWith("/reference:", StringComparison.Ordinal) && !(a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase))).ToList(); - var msbuildRefArgs = msbuildArgsToVerify.Where(static a => a.StartsWith("/reference:", StringComparison.Ordinal)).ToList(); - var msbuildAnalyzerArgs = msbuildArgsToVerify.Where(a => a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)).ToList(); - var msbuildOtherArgs = msbuildArgsToVerify.Where(a => !a.StartsWith("/reference:", StringComparison.Ordinal) && !(a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase))).ToList(); - cscOnlyRefArgs.Should().NotBeEmpty( - "framework references should be resolved from FrameworkList.xml"); - cscOnlyRefArgs.Should().BeEquivalentTo(msbuildRefArgs, - "the generated file might be outdated, run this test locally to regenerate it"); - cscOnlyAnalyzerArgs.Should().NotBeEmpty( - "framework analyzers should be resolved from FrameworkList.xml"); - cscOnlyAnalyzerArgs.Should().BeEquivalentTo(msbuildAnalyzerArgs, - "the generated file might be outdated, run this test locally to regenerate it"); - cscOnlyOtherArgs.Should().Equal(msbuildOtherArgs, - "the generated file might be outdated, run this test locally to regenerate it"); + Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); - static CompilerCall FindCompilerCall(string binaryLogPath) - { - using var reader = BinaryLogReader.Create(binaryLogPath); - return reader.ReadAllCompilerCalls().Should().ContainSingle().Subject; - } + code = code.Replace("v1", "v2"); + File.WriteAllText(originalPath, code, utf8NoBom); - static string NormalizePathArg(string arg) - { - return CSharpCompilerCommand.IsPathOption(arg, out int colonIndex) - ? string.Concat(arg.AsSpan(0, colonIndex + 1), NormalizePath(arg.Substring(colonIndex + 1))) - : NormalizePath(arg); - } + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + } - static string NormalizePath(string path) - { - return PathUtility.GetPathWithForwardSlashes(TestPathUtility.ResolveTempPrefixLink(path)); - } + /// + /// optimization currently does not support #:project references and hence is disabled if those are present. + /// See . + /// + [Fact] + public void UpToDate_ProjectReferences() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); - static string RemoveQuotes(string arg) - { - return arg.Replace("\"", string.Empty); - } + var libDir = Path.Join(testInstance.Path, "Lib"); + Directory.CreateDirectory(libDir); - static string? GetGeneratedMethodName(string assetFileName) - { - return assetFileName switch + File.WriteAllText(Path.Join(libDir, "Lib.csproj"), $""" + + + {ToolsetInfo.CurrentTargetFramework} + + + """); + + var libPath = Path.Join(libDir, "Lib.cs"); + var libCode = """ + namespace Lib; + public class LibClass { - $".NETCoreApp,Version=v{ToolsetInfo.CurrentTargetFrameworkVersion}.AssemblyAttributes.cs" => "AssemblyAttributes", - $"{fileName}.GlobalUsings.g.cs" => "GlobalUsings", - $"{fileName}.AssemblyInfo.cs" => "AssemblyInfo", - $"{fileName}.GeneratedMSBuildEditorConfig.editorconfig" => "GeneratedMSBuildEditorConfig", - $"{programName}{FileNameSuffixes.RuntimeConfigJson}" => "RuntimeConfig", - _ => null, - }; - } - } + public static string GetMessage() => "Hello from Lib v1"; + } + """; + File.WriteAllText(libPath, libCode); - /// - /// Verifies that csc-only runs emit auxiliary files equivalent to msbuild-based runs. - /// - [Theory] - [InlineData("Program.cs")] - [InlineData("test.cs")] - [InlineData("noext")] - public void CscVsMSBuild(string fileName) - { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - string entryPointPath = Path.Join(testInstance.Path, fileName); - File.WriteAllText(entryPointPath, $""" - #!/test - {s_program} - """); + var appDir = Path.Join(testInstance.Path, "App"); + Directory.CreateDirectory(appDir); - string programName = Path.GetFileNameWithoutExtension(fileName); + var code = """ + #:project ../Lib + Console.WriteLine("v1 " + Lib.LibClass.GetMessage()); + """; + + var programPath = Path.Join(appDir, "Program.cs"); + File.WriteAllText(programPath, code); // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(entryPointPath); + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - var artifactsBackupDir = Path.ChangeExtension(artifactsDir, ".bak"); - if (Directory.Exists(artifactsBackupDir)) Directory.Delete(artifactsBackupDir, recursive: true); - - // Build using CSC. - new DotnetCommand(Log, "run", fileName, "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut($""" - {CliCommandStrings.NoBinaryLogBecauseRunningJustCsc} - Hello from {programName} - """); - // Backup the artifacts directory. - Directory.Move(artifactsDir, artifactsBackupDir); + var programFileName = "App/Program.cs"; - // Build using MSBuild. - new DotnetCommand(Log, "run", fileName, "-bl", "--no-cache") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut($"Hello from {programName}"); + Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v1", programFileName: programFileName); - // Check that files generated by MSBuild and CSC-only runs are equivalent. - var cscOnlyFiles = Directory.EnumerateFiles(artifactsBackupDir, "*", SearchOption.AllDirectories) - .Where(f => - Path.GetDirectoryName(f) != artifactsBackupDir && // exclude top-level marker files - Path.GetFileName(f) != programName && // binary on unix - Path.GetExtension(f) is not (".dll" or ".exe" or ".pdb")); // other binaries - bool hasErrors = false; - foreach (var cscOnlyFile in cscOnlyFiles) - { - var relativePath = Path.GetRelativePath(relativeTo: artifactsBackupDir, path: cscOnlyFile); - var msbuildFile = Path.Join(artifactsDir, relativePath); + // We cannot detect changes in referenced projects, so we always rebuild. + Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v1", programFileName: programFileName); - if (!File.Exists(msbuildFile)) - { - throw new InvalidOperationException($"File exists in CSC-only run but not in MSBuild run: {cscOnlyFile}"); - } + libCode = libCode.Replace("v1", "v2"); + File.WriteAllText(libPath, libCode); - var cscOnlyFileText = File.ReadAllText(cscOnlyFile); - var msbuildFileText = File.ReadAllText(msbuildFile); - if (cscOnlyFileText.ReplaceLineEndings() != msbuildFileText.ReplaceLineEndings()) - { - Log.WriteLine($"File differs between MSBuild and CSC-only runs (if this is expected, run test '{nameof(CscArguments)}' locally to re-generate the template): {cscOnlyFile}"); - const int limit = 3_000; - if (cscOnlyFileText.Length < limit && msbuildFileText.Length < limit) - { - Log.WriteLine("MSBuild file content:"); - Log.WriteLine(msbuildFileText); - Log.WriteLine("CSC-only file content:"); - Log.WriteLine(cscOnlyFileText); - } - else - { - Log.WriteLine($"MSBuild file size: {msbuildFileText.Length} chars"); - Log.WriteLine($"CSC-only file size: {cscOnlyFileText.Length} chars"); - } - hasErrors = true; - } - } - hasErrors.Should().BeFalse("some file contents do not match, see the test output for details"); + Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v2", programFileName: programFileName); } + /// + /// optimization currently does not support #:ref references and hence is disabled if those are present. + /// Analogous to . + /// [Fact] - public void UpToDate() + public void UpToDate_RefDirectives() { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Console.WriteLine("Hello v1"); - """); + var testInstance = _testAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); - // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(Path.Join(testInstance.Path, "Program.cs")); - if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + var libPath = Path.Join(testInstance.Path, "lib.cs"); + var libCode = """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "v1"; + } + """; + File.WriteAllText(libPath, libCode); - Build(testInstance, BuildLevel.Csc, expectedOutput: "Hello v1"); + var programCode = """ + #:ref lib.cs + Console.WriteLine("Hello " + MyLib.Greeter.Greet()); + """; - Build(testInstance, BuildLevel.None, expectedOutput: "Hello v1"); + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, programCode); - Build(testInstance, BuildLevel.None, expectedOutput: "Hello v1"); + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - // Change the source file (a rebuild is necessary). - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); - Build(testInstance, BuildLevel.Csc); + // We cannot detect changes in referenced files, so we always rebuild. + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); - Build(testInstance, BuildLevel.None); + libCode = libCode.Replace("v1", "v2"); + File.WriteAllText(libPath, libCode); - // Change an unrelated source file (no rebuild necessary). - File.WriteAllText(Path.Join(testInstance.Path, "Program2.cs"), "test"); + Build(testInstance, BuildLevel.All, expectedOutput: "Hello v2"); + } - Build(testInstance, BuildLevel.None); + /// + /// optimization considers default items. + /// Also tests optimization. + /// (We cannot test because that optimization doesn't support neither #:property nor #:sdk which we need to enable default items.) + /// See . + /// + [Theory, CombinatorialData] + public void UpToDate_DefaultItems(bool optOut) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var code = $""" + {(optOut ? "#:property FileBasedProgramCanSkipMSBuild=false" : "")} + #:property EnableDefaultEmbeddedResourceItems=true + {s_programReadingEmbeddedResource} + """; + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), code); - // Add an implicit build file (a rebuild is necessary). - string buildPropsFile = Path.Join(testInstance.Path, "Directory.Build.props"); - File.WriteAllText(buildPropsFile, """ - - - $(DefineConstants);CUSTOM_DEFINE - - - """); + Build(testInstance, BuildLevel.All, expectedOutput: "Resource not found"); - Build(testInstance, BuildLevel.All, expectedOutput: """ - Hello from Program - Custom define - """); + File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx); - Build(testInstance, BuildLevel.None, expectedOutput: """ - Hello from Program - Custom define - """); + if (!optOut) + { + // Adding a default item is currently not recognized (https://github.com/dotnet/sdk/issues/50912). + Build(testInstance, BuildLevel.None, expectedOutput: "Resource not found"); + Build(testInstance, BuildLevel.All, args: ["--no-cache"], expectedOutput: "[MyString, TestValue]"); + } + else + { + Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, TestValue]"); + } - // Change the implicit build file (a rebuild is necessary). - string importedFile = Path.Join(testInstance.Path, "Settings.props"); - File.WriteAllText(importedFile, """ - - - """); - File.WriteAllText(buildPropsFile, """ - - - - """); + // Update the RESX file. + File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx.Replace("TestValue", "UpdatedValue")); - Build(testInstance, BuildLevel.All); + Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, UpdatedValue]"); - // Change the imported build file (this is not recognized). - File.WriteAllText(importedFile, """ - - - $(DefineConstants);CUSTOM_DEFINE - - - """); + // Update the C# file. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "//v2\n" + code); - Build(testInstance, BuildLevel.None); + Build(testInstance, optOut ? BuildLevel.All : BuildLevel.Csc, expectedOutput: "[MyString, UpdatedValue]"); - // Force rebuild. - Build(testInstance, BuildLevel.All, args: ["--no-cache"], expectedOutput: """ - Hello from Program - Custom define - """); + // Update the RESX file again (to verify the CSC only compilation didn't corrupt the list of additional files in the cache). + File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx.Replace("TestValue", "UpdatedValue2")); - // Remove an implicit build file (a rebuild is necessary). - File.Delete(buildPropsFile); - Build(testInstance, BuildLevel.Csc); + Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, UpdatedValue2]"); + } - // Force rebuild. - Build(testInstance, BuildLevel.All, args: ["--no-cache"]); - - Build(testInstance, BuildLevel.None); - - // Pass argument (no rebuild necessary). - Build(testInstance, BuildLevel.None, args: ["--", "test-arg"], expectedOutput: """ - echo args:test-arg - Hello from Program - """); - - // Change config (a rebuild is necessary). - Build(testInstance, BuildLevel.All, args: ["-c", "Release"], expectedOutput: """ - Hello from Program - Release config - """); - - // Keep changed config (no rebuild necessary). - Build(testInstance, BuildLevel.None, args: ["-c", "Release"], expectedOutput: """ - Hello from Program - Release config + /// + /// Similar to but for .razor files instead of .resx files. + /// + [Fact] + public void UpToDate_DefaultItems_Razor() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var programFileName = "MyRazorApp.cs"; + File.WriteAllText(Path.Join(testInstance.Path, programFileName), """ + #:sdk Microsoft.NET.Sdk.Web + _ = new MyRazorApp.MyCoolApp(); + Console.WriteLine("Hello from Program"); """); - // Change config back (a rebuild is necessary). - Build(testInstance, BuildLevel.Csc); + var razorFilePath = Path.Join(testInstance.Path, "MyCoolApp.razor"); + File.WriteAllText(razorFilePath, ""); - // Build with a failure. - new DotnetCommand(Log, ["run", "Program.cs", "-p:LangVersion=Invalid"]) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdOutContaining("error CS1617"); // Invalid option 'Invalid' for /langversion. + Build(testInstance, BuildLevel.All, programFileName: programFileName); - // A rebuild is necessary since the last build failed. - Build(testInstance, BuildLevel.Csc); - } + Build(testInstance, BuildLevel.None, programFileName: programFileName); - [Fact] - public void UpToDate_InvalidOptions() - { - var testInstance = _testAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.Delete(razorFilePath); - new DotnetCommand(Log, "run", "Program.cs", "--no-cache", "--no-build") + new DotnetCommand(Log, "run", programFileName) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(string.Format(CliCommandStrings.CannotCombineOptions, "--no-cache", "--no-build")); + // error CS0246: The type or namespace name 'MyRazorApp' could not be found + .And.HaveStdOutContaining("error CS0246"); } - /// - /// optimization should see through symlinks. - /// See . - /// [Fact] - public void UpToDate_SymbolicLink() + public void CscOnly() { - var testInstance = _testAssetsManager.CreateTestDirectory(); + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - var originalPath = Path.Join(testInstance.Path, "original.cs"); - var code = """ - #!/usr/bin/env dotnet + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ Console.WriteLine("v1"); - """; - var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - File.WriteAllText(originalPath, code, utf8NoBom); - - var programFileName = "linked"; - var programPath = Path.Join(testInstance.Path, programFileName); - - File.CreateSymbolicLink(path: programPath, pathToTarget: originalPath); + """); // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(Path.Join(testInstance.Path, "Program.cs")); if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); + Build(testInstance, BuildLevel.Csc, expectedOutput: "v1"); - Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine("v2"); + #if !DEBUG + Console.WriteLine("Release config"); + #endif + """); - code = code.Replace("v1", "v2"); - File.WriteAllText(originalPath, code, utf8NoBom); + Build(testInstance, BuildLevel.Csc, expectedOutput: "v2"); - Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + // Customizing a property forces MSBuild to be used. + Build(testInstance, BuildLevel.All, args: ["-c", "Release"], expectedOutput: """ + v2 + Release config + """); } - /// - /// Similar to but with a chain of symlinks. - /// [Fact] - public void UpToDate_SymbolicLink2() + public void CscOnly_CompilationDiagnostics() { - var testInstance = _testAssetsManager.CreateTestDirectory(); - - var originalPath = Path.Join(testInstance.Path, "original.cs"); - var code = """ - #!/usr/bin/env dotnet - Console.WriteLine("v1"); - """; - var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - File.WriteAllText(originalPath, code, utf8NoBom); - - var intermediateFileName = "linked1"; - var intermediatePath = Path.Join(testInstance.Path, intermediateFileName); - - File.CreateSymbolicLink(path: intermediatePath, pathToTarget: originalPath); - - var programFileName = "linked2"; - var programPath = Path.Join(testInstance.Path, programFileName); - - File.CreateSymbolicLink(path: programPath, pathToTarget: intermediatePath); - - // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); - if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - Build(testInstance, BuildLevel.All, expectedOutput: "v1", programFileName: programFileName); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + string x = null; + Console.WriteLine("ran" + x); + """); - Build(testInstance, BuildLevel.None, expectedOutput: "v1", programFileName: programFileName); + new DotnetCommand(Log, "run", "Program.cs", "-bl") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) + // warning CS8600: Converting null literal or possible null value to non-nullable type. + .And.HaveStdOutContaining("warning CS8600") + .And.HaveStdOutContaining("ran"); - code = code.Replace("v1", "v2"); - File.WriteAllText(originalPath, code, utf8NoBom); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.Write + """); - Build(testInstance, BuildLevel.Csc, expectedOutput: "v2", programFileName: programFileName); + new DotnetCommand(Log, "run", "Program.cs", "-bl") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) + // error CS1002: ; expected + .And.HaveStdOutContaining("error CS1002") + .And.HaveStdErrContaining(CliCommandStrings.RunCommandException); } /// - /// optimization currently does not support #:project references and hence is disabled if those are present. - /// See . + /// Checks that the DOTNET_ROOT env var is set the same in csc mode as in msbuild mode. /// [Fact] - public void UpToDate_ProjectReferences() + public void CscOnly_DotNetRoot() { - var testInstance = _testAssetsManager.CreateTestDirectory(); - - var libDir = Path.Join(testInstance.Path, "Lib"); - Directory.CreateDirectory(libDir); - - File.WriteAllText(Path.Join(libDir, "Lib.csproj"), $""" - - - {ToolsetInfo.CurrentTargetFramework} - - - """); - - var libPath = Path.Join(libDir, "Lib.cs"); - var libCode = """ - namespace Lib; - public class LibClass + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + foreach (var entry in Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process) + .Cast() + .Where(e => ((string)e.Key).StartsWith("DOTNET_ROOT"))) { - public static string GetMessage() => "Hello from Lib v1"; + Console.WriteLine($"{entry.Key}={entry.Value}"); } - """; - File.WriteAllText(libPath, libCode); - - var appDir = Path.Join(testInstance.Path, "App"); - Directory.CreateDirectory(appDir); - - var code = """ - #:project ../Lib - Console.WriteLine("v1 " + Lib.LibClass.GetMessage()); - """; + """); - var programPath = Path.Join(appDir, "Program.cs"); - File.WriteAllText(programPath, code); + var expectedDotNetRoot = SdkTestContext.Current.ToolsetUnderTest.DotNetRoot; - // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); - if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + var cscResult = new DotnetCommand(Log, "run", "Program.cs", "-bl") + .WithWorkingDirectory(testInstance.Path) + .Execute(); - var programFileName = "App/Program.cs"; + cscResult.Should().Pass() + .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) + .And.HaveStdOutContaining("DOTNET_ROOT") + .And.HaveStdOutContaining($"={expectedDotNetRoot}"); - Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v1", programFileName: programFileName); + // Add an implicit build file to force use of msbuild instead of csc. + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), ""); - // We cannot detect changes in referenced projects, so we always rebuild. - Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v1", programFileName: programFileName); + var msbuildResult = new DotnetCommand(Log, "run", "Program.cs", "-bl") + .WithWorkingDirectory(testInstance.Path) + .Execute(); - libCode = libCode.Replace("v1", "v2"); - File.WriteAllText(libPath, libCode); + msbuildResult.Should().Pass() + .And.NotHaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) + .And.HaveStdOutContaining("DOTNET_ROOT") + .And.HaveStdOutContaining($"={expectedDotNetRoot}"); - Build(testInstance, BuildLevel.All, expectedOutput: "v1 Hello from Lib v2", programFileName: programFileName); + // The set of DOTNET_ROOT env vars should be the same in both cases. + var cscVars = cscResult.StdOut! + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith("DOTNET_ROOT")); + var msbuildVars = msbuildResult.StdOut! + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith("DOTNET_ROOT")); + cscVars.Should().BeEquivalentTo(msbuildVars); } /// - /// optimization currently does not support #:ref references and hence is disabled if those are present. - /// Analogous to . + /// In CSC-only mode, the SDK needs to manually create intermediate files + /// like GlobalUsings.g.cs which are normally generated by MSBuild targets. + /// This tests the SDK recreates the files when they are outdated. /// [Fact] - public void UpToDate_RefDirectives() + public void CscOnly_IntermediateFiles() { - var testInstance = _testAssetsManager.CreateTestDirectory(); - EnableRefDirective(testInstance); - - var libPath = Path.Join(testInstance.Path, "lib.cs"); - var libCode = """ - #:property OutputType=Library - namespace MyLib; - public static class Greeter - { - public static string Greet() => "v1"; - } - """; - File.WriteAllText(libPath, libCode); - - var programCode = """ - #:ref lib.cs - Console.WriteLine("Hello " + MyLib.Greeter.Greet()); - """; - - var programPath = Path.Join(testInstance.Path, "Program.cs"); - File.WriteAllText(programPath, programCode); - - // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(programPath); - if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - - Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); - - // We cannot detect changes in referenced files, so we always rebuild. - Build(testInstance, BuildLevel.All, expectedOutput: "Hello v1"); - - libCode = libCode.Replace("v1", "v2"); - File.WriteAllText(libPath, libCode); - - Build(testInstance, BuildLevel.All, expectedOutput: "Hello v2"); - } - - /// - /// optimization considers default items. - /// Also tests optimization. - /// (We cannot test because that optimization doesn't support neither #:property nor #:sdk which we need to enable default items.) - /// See . - /// - [Theory, CombinatorialData] - public void UpToDate_DefaultItems(bool optOut) - { - var testInstance = _testAssetsManager.CreateTestDirectory(); - var code = $""" - {(optOut ? "#:property FileBasedProgramCanSkipMSBuild=false" : "")} - #:property EnableDefaultEmbeddedResourceItems=true - {s_programReadingEmbeddedResource} - """; - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), code); - - Build(testInstance, BuildLevel.All, expectedOutput: "Resource not found"); - - File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx); - - if (!optOut) - { - // Adding a default item is currently not recognized (https://github.com/dotnet/sdk/issues/50912). - Build(testInstance, BuildLevel.None, expectedOutput: "Resource not found"); - Build(testInstance, BuildLevel.All, args: ["--no-cache"], expectedOutput: "[MyString, TestValue]"); - } - else - { - Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, TestValue]"); - } - - // Update the RESX file. - File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx.Replace("TestValue", "UpdatedValue")); - - Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, UpdatedValue]"); - - // Update the C# file. - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), "//v2\n" + code); - - Build(testInstance, optOut ? BuildLevel.All : BuildLevel.Csc, expectedOutput: "[MyString, UpdatedValue]"); - - // Update the RESX file again (to verify the CSC only compilation didn't corrupt the list of additional files in the cache). - File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), s_resx.Replace("TestValue", "UpdatedValue2")); - - Build(testInstance, BuildLevel.All, expectedOutput: "[MyString, UpdatedValue2]"); - } - - /// - /// Similar to but for .razor files instead of .resx files. - /// - [Fact] - public void UpToDate_DefaultItems_Razor() - { - var testInstance = _testAssetsManager.CreateTestDirectory(); - var programFileName = "MyRazorApp.cs"; - File.WriteAllText(Path.Join(testInstance.Path, programFileName), """ - #:sdk Microsoft.NET.Sdk.Web - _ = new MyRazorApp.MyCoolApp(); - Console.WriteLine("Hello from Program"); - """); - - var razorFilePath = Path.Join(testInstance.Path, "MyCoolApp.razor"); - File.WriteAllText(razorFilePath, ""); - - Build(testInstance, BuildLevel.All, programFileName: programFileName); - - Build(testInstance, BuildLevel.None, programFileName: programFileName); - - File.Delete(razorFilePath); - - new DotnetCommand(Log, "run", programFileName) - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - // error CS0246: The type or namespace name 'MyRazorApp' could not be found - .And.HaveStdOutContaining("error CS0246"); - } - - [Fact] - public void CscOnly() - { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Console.WriteLine("v1"); - """); - - // Remove artifacts from possible previous runs of this test. - var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(Path.Join(testInstance.Path, "Program.cs")); - if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); - - Build(testInstance, BuildLevel.Csc, expectedOutput: "v1"); - - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Console.WriteLine("v2"); - #if !DEBUG - Console.WriteLine("Release config"); - #endif - """); - - Build(testInstance, BuildLevel.Csc, expectedOutput: "v2"); - - // Customizing a property forces MSBuild to be used. - Build(testInstance, BuildLevel.All, args: ["-c", "Release"], expectedOutput: """ - v2 - Release config - """); - } - - [Fact] - public void CscOnly_CompilationDiagnostics() - { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - string x = null; - Console.WriteLine("ran" + x); - """); - - new DotnetCommand(Log, "run", "Program.cs", "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) - // warning CS8600: Converting null literal or possible null value to non-nullable type. - .And.HaveStdOutContaining("warning CS8600") - .And.HaveStdOutContaining("ran"); - - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Console.Write - """); - - new DotnetCommand(Log, "run", "Program.cs", "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Fail() - .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) - // error CS1002: ; expected - .And.HaveStdOutContaining("error CS1002") - .And.HaveStdErrContaining(CliCommandStrings.RunCommandException); - } - - /// - /// Checks that the DOTNET_ROOT env var is set the same in csc mode as in msbuild mode. - /// - [Fact] - public void CscOnly_DotNetRoot() - { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - foreach (var entry in Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process) - .Cast() - .Where(e => ((string)e.Key).StartsWith("DOTNET_ROOT"))) - { - Console.WriteLine($"{entry.Key}={entry.Value}"); - } - """); - - var expectedDotNetRoot = SdkTestContext.Current.ToolsetUnderTest.DotNetRoot; - - var cscResult = new DotnetCommand(Log, "run", "Program.cs", "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute(); - - cscResult.Should().Pass() - .And.HaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) - .And.HaveStdOutContaining("DOTNET_ROOT") - .And.HaveStdOutContaining($"={expectedDotNetRoot}"); - - // Add an implicit build file to force use of msbuild instead of csc. - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), ""); - - var msbuildResult = new DotnetCommand(Log, "run", "Program.cs", "-bl") - .WithWorkingDirectory(testInstance.Path) - .Execute(); - - msbuildResult.Should().Pass() - .And.NotHaveStdOutContaining(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc) - .And.HaveStdOutContaining("DOTNET_ROOT") - .And.HaveStdOutContaining($"={expectedDotNetRoot}"); - - // The set of DOTNET_ROOT env vars should be the same in both cases. - var cscVars = cscResult.StdOut! - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Where(line => line.StartsWith("DOTNET_ROOT")); - var msbuildVars = msbuildResult.StdOut! - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Where(line => line.StartsWith("DOTNET_ROOT")); - cscVars.Should().BeEquivalentTo(msbuildVars); - } - - /// - /// In CSC-only mode, the SDK needs to manually create intermediate files - /// like GlobalUsings.g.cs which are normally generated by MSBuild targets. - /// This tests the SDK recreates the files when they are outdated. - /// - [Fact] - public void CscOnly_IntermediateFiles() - { - var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Expression> e = () => 1 + 1; - Console.WriteLine(e); - """); + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Expression> e = () => 1 + 1; + Console.WriteLine(e); + """); // Remove artifacts from possible previous runs of this test. var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(Path.Join(testInstance.Path, "Program.cs")); @@ -2432,143 +1932,642 @@ public void MSBuildGet_Consistent(bool success, string subcommand, params string """); - var fileBasedResult = new DotnetCommand(Log, [subcommand, "Program.cs", .. args]) + var fileBasedResult = new DotnetCommand(Log, [subcommand, "Program.cs", .. args]) + .WithWorkingDirectory(testInstance.Path) + .Execute(); + + var fileBasedFiles = ReadFiles(); + + File.WriteAllText(Path.Join(testInstance.Path, "Program.csproj"), s_consoleProject); + + var projectBasedResult = new DotnetCommand(Log, [subcommand, .. args]) + .WithWorkingDirectory(testInstance.Path) + .Execute(); + + var projectBasedFiles = ReadFiles(); + + fileBasedResult.StdOut.Should().Be(projectBasedResult.StdOut); + fileBasedResult.StdErr!.Replace("Program.cs.csproj", "Program.csproj").Should().Be(projectBasedResult.StdErr); + fileBasedResult.ExitCode.Should().Be(projectBasedResult.ExitCode).And.Be(success ? 0 : 1); + fileBasedFiles.Should().Equal(projectBasedFiles); + + Dictionary ReadFiles() + { + var result = new DirectoryInfo(testInstance.Path) + .EnumerateFiles() + .ExceptBy(["Program.cs", "Directory.Build.props", "Program.csproj"], f => f.Name) + .ToDictionary(f => f.Name, f => File.ReadAllText(f.FullName)); + + foreach (var (file, text) in result) + { + Log.WriteLine($"File '{file}':"); + Log.WriteLine(text); + File.Delete(Path.Join(testInstance.Path, file)); + } + + return result; + } + } + + /// + /// Regression test for https://github.com/dotnet/sdk/issues/52714. + /// The virtual project's must survive GC + /// even after being evicted from MSBuild's strong cache (LRU of size N). + /// We force eviction via MSBUILDPROJECTROOTELEMENTCACHESIZE=1 + /// and trigger GC via an inline task during NuGet restore. + /// Without the fix (strong reference in VirtualProjectBuilder._projectRootElement), + /// this fails with MSB4025 "The project file could not be loaded". + /// + [Fact] + public void VirtualProject_SurvivesGCDuringRestore() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine("Hello from virtual project"); + """); + + // Directory.Build.targets that forces GC during restore, + // after SDK imports have already evicted the virtual PRE from the strong cache. + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ + + + + + + + + <_ForceGCTask /> + + + """); + + new DotnetCommand(Log, "run", "--no-cache", "Program.cs") + // A cache size of 1 ensures the virtual PRE is evicted from the strong cache + // as soon as any SDK .targets/.props file is loaded during evaluation. + .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from virtual project"); + } + + /// + /// Same as but for #:ref referenced projects. + /// The referenced project's must also survive GC. + /// + [Fact] + public void VirtualProject_SurvivesGCDuringRestore_RefDirective() + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello from ref"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #:ref Lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // Directory.Build.targets that forces GC during restore, + // after SDK imports have already evicted the virtual PRE from the strong cache. + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ + + + + + + + + <_ForceGCTask /> + + + """); + + new DotnetCommand(Log, "run", "--no-cache", "Program.cs") + // A cache size of 1 ensures the virtual PRE is evicted from the strong cache + // as soon as any SDK .targets/.props file is loaded during evaluation. + .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from ref"); + } + + /// + /// Verifies that msbuild-based runs use CSC args equivalent to csc-only runs. + /// Can regenerate CSC arguments template in . + /// + [Fact] + public void CscArguments() + { + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + const string programName = "TestProgram"; + const string fileName = $"{programName}.cs"; + string entryPointPath = Path.Join(testInstance.Path, fileName); + File.WriteAllText(entryPointPath, s_program); + + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(entryPointPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + + // Build using MSBuild. + new DotnetCommand(Log, "run", fileName, "-bl", "--no-cache") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut($"Hello from {programName}"); + + // Find the csc args used by the build. + var msbuildCall = FindCompilerCall(Path.Join(testInstance.Path, "msbuild.binlog")); + var msbuildCallArgs = msbuildCall.GetArguments(); + var msbuildCallArgsString = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(msbuildCallArgs); + + // Generate argument template code. + string sdkPath = NormalizePath(SdkTestContext.Current.ToolsetUnderTest.SdkFolderUnderTest); + string dotNetRootPath = NormalizePath(SdkTestContext.Current.ToolsetUnderTest.DotNetRoot); + string nuGetCachePath = NormalizePath(SdkTestContext.Current.NuGetCachePath!); + string artifactsDirNormalized = NormalizePath(artifactsDir); + string objPath = $"{artifactsDirNormalized}/obj/debug"; + string entryPointPathNormalized = NormalizePath(entryPointPath); + var msbuildArgsToVerify = new List(); + var nuGetPackageFilePaths = new List(); + bool referenceSpreadInserted = false; + bool analyzerSpreadInserted = false; + const string NetCoreAppRefPackPath = "packs/Microsoft.NETCore.App.Ref/"; + var code = new StringBuilder(); + code.AppendLine($$""" + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + using System.Text.Json; + + namespace Microsoft.DotNet.Cli.Commands.Run; + + // Generated by test `{{nameof(RunFileTests_CscOnlyAndApi)}}.{{nameof(CscArguments)}}`. + partial class CSharpCompilerCommand + { + private IEnumerable GetCscArguments( + string objDir, + string binDir) + { + return + [ + """); + foreach (var arg in msbuildCallArgs) + { + // This option needs to be passed on the command line, not in an RSP file. + if (arg is "/noconfig") + { + continue; + } + + // We don't need to generate a ref assembly. + if (arg.StartsWith("/refout:", StringComparison.Ordinal)) + { + continue; + } + + // There should be no source link arguments. + if (arg.StartsWith("/sourcelink:", StringComparison.Ordinal)) + { + Assert.Fail($"Unexpected source link argument: {arg}"); + } + + // PreferredUILang is normally not set by default but can be in builds, so ignore it. + if (arg.StartsWith("/preferreduilang:", StringComparison.Ordinal)) + { + continue; + } + + bool needsInterpolation = false; + bool fromNuGetPackage = false; + + // Normalize slashes in paths. + string rewritten = NormalizePathArg(arg); + + // Remove quotes. + rewritten = RemoveQuotes(rewritten); + + string msbuildArgToVerify = rewritten; + + // Use variable SDK path. + if (rewritten.Contains(sdkPath, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(sdkPath, "{SdkPath}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable .NET root path. + if (rewritten.Contains(dotNetRootPath, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(dotNetRootPath, "{DotNetRootPath}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable NuGet cache path. + if (rewritten.Contains(nuGetCachePath, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(nuGetCachePath, "{NuGetCachePath}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + fromNuGetPackage = true; + } + + // Use variable intermediate dir path. + if (rewritten.Contains(objPath, StringComparison.OrdinalIgnoreCase)) + { + // We want to emit the resulting DLL directly into the bin folder. + bool isOut = arg.StartsWith("/out", StringComparison.Ordinal); + string replacement = isOut ? "{binDir}" : "{objDir}"; + + if (isOut) + { + msbuildArgToVerify = msbuildArgToVerify.Replace("/obj/", "/bin/", StringComparison.OrdinalIgnoreCase); + } + + rewritten = rewritten.Replace(objPath, replacement, StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable file path. + if (rewritten.Contains(entryPointPathNormalized, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(entryPointPathNormalized, "{" + nameof(CSharpCompilerCommand.EntryPointFileFullPath) + "}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable file name. + if (rewritten.Contains(fileName, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(fileName, "{FileName}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable program name. + if (rewritten.Contains(programName, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(programName, "{FileNameWithoutExtension}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Use variable runtime version. + if (rewritten.Contains(CSharpCompilerCommand.RuntimeVersion, StringComparison.OrdinalIgnoreCase)) + { + rewritten = rewritten.Replace(CSharpCompilerCommand.RuntimeVersion, "{" + nameof(CSharpCompilerCommand.RuntimeVersion) + "}", StringComparison.OrdinalIgnoreCase); + needsInterpolation = true; + } + + // Ignore `/analyzerconfig` which is not variable (so it comes from the machine or sdk repo). + if (!needsInterpolation && arg.StartsWith("/analyzerconfig", StringComparison.Ordinal)) + { + continue; + } + + // Use GetFrameworkReferenceArguments() for framework references instead of hard-coding them. + if (arg.StartsWith("/reference:", StringComparison.Ordinal)) + { + if (!referenceSpreadInserted) + { + code.AppendLine(""" + .. GetFrameworkReferenceArguments(), + """); + referenceSpreadInserted = true; + } + + msbuildArgsToVerify.Add(msbuildArgToVerify); + continue; + } + + // Use GetFrameworkAnalyzerArguments() for targeting-pack analyzers instead of hard-coding them. + if (arg.StartsWith("/analyzer:", StringComparison.Ordinal) + && rewritten.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)) + { + if (!analyzerSpreadInserted) + { + code.AppendLine(""" + .. GetFrameworkAnalyzerArguments(), + """); + analyzerSpreadInserted = true; + } + + msbuildArgsToVerify.Add(msbuildArgToVerify); + continue; + } + + string prefix = needsInterpolation ? "$" : string.Empty; + + code.AppendLine($""" + {prefix}"{rewritten}", + """); + + msbuildArgsToVerify.Add(msbuildArgToVerify); + + if (fromNuGetPackage) + { + nuGetPackageFilePaths.Add(CSharpCompilerCommand.IsPathOption(rewritten, out int colonIndex) + ? rewritten.Substring(colonIndex + 1) + : rewritten); + } + } + code.AppendLine(""" + ]; + } + + /// + /// Files that come from referenced NuGet packages (e.g., analyzers for NativeAOT) need to be checked specially (if they don't exist, MSBuild needs to run). + /// + public static IEnumerable GetPathsOfCscInputsFromNuGetCache() + { + return + [ + """); + foreach (var nuGetPackageFilePath in nuGetPackageFilePaths) + { + code.AppendLine($""" + $"{nuGetPackageFilePath}", + """); + } + code.AppendLine(""" + ]; + } + """); + + // Generate file content templates. + var baseDirectory = TestPathUtility.ResolveTempPrefixLink(Path.GetDirectoryName(entryPointPath)!); + var replacements = new List<(string, string)> + { + (TestPathUtility.ResolveTempPrefixLink(entryPointPath), nameof(CSharpCompilerCommand.EntryPointFileFullPath)), + (baseDirectory + Path.DirectorySeparatorChar, nameof(CSharpCompilerCommand.BaseDirectoryWithTrailingSeparator)), + (baseDirectory, nameof(CSharpCompilerCommand.BaseDirectory)), + (programName, nameof(CSharpCompilerCommand.FileNameWithoutExtension)), + (CSharpCompilerCommand.TargetFrameworkVersion, nameof(CSharpCompilerCommand.TargetFrameworkVersion)), + (CSharpCompilerCommand.TargetFramework, nameof(CSharpCompilerCommand.TargetFramework)), + (CSharpCompilerCommand.DefaultRuntimeVersion, nameof(CSharpCompilerCommand.DefaultRuntimeVersion)), + }; + var emittedFiles = Directory.EnumerateFiles(artifactsDir, "*", SearchOption.AllDirectories).Order(); + foreach (var emittedFile in emittedFiles) + { + var emittedFileName = Path.GetFileName(emittedFile); + var generatedMethodName = GetGeneratedMethodName(emittedFileName); + if (generatedMethodName is null) + { + Log.WriteLine($"Skipping unrecognized file '{emittedFile}'."); + continue; + } + + var emittedFileContent = File.ReadAllText(emittedFile); + + string interpolatedString = emittedFileContent; + string interpolationPrefix; + + if (emittedFileName.EndsWith(".json", StringComparison.Ordinal)) + { + interpolationPrefix = "$$"; + foreach (var (key, value) in replacements) + { + interpolatedString = interpolatedString.Replace(JsonSerializer.Serialize(key), "{{JsonSerializer.Serialize(" + value + ")}}"); + } + } + else + { + interpolationPrefix = "$"; + foreach (var (key, value) in replacements) + { + interpolatedString = interpolatedString.Replace(key, "{" + value + "}"); + } + } + + if (interpolatedString == emittedFileContent) + { + interpolationPrefix = ""; + } + + code.AppendLine($$"""" + + private string Get{{generatedMethodName}}Content() + { + return {{interpolationPrefix}}""" + {{interpolatedString}} + """; + } + """"); + } + + code.AppendLine(""" + } + """); + + // Save the code. + var codeFolder = new DirectoryInfo(Path.Join( + SdkTestContext.Current.ToolsetUnderTest.RepoRoot, + "src", "Cli", "dotnet", "Commands", "Run")); + var nonGeneratedFile = codeFolder.File("CSharpCompilerCommand.cs"); + if (!nonGeneratedFile.Exists) + { + Log.WriteLine($"Skipping code generation because file does not exist: {nonGeneratedFile.FullName}"); + } + else + { + var codeFilePath = codeFolder.File("CSharpCompilerCommand.Generated.cs"); + var existingText = codeFilePath.Exists ? File.ReadAllText(codeFilePath.FullName) : string.Empty; + var newText = code.ToString(); + if (existingText != newText) + { + Log.WriteLine($"{codeFilePath.FullName} needs to be updated:"); + Log.WriteLine(newText); + if (Env.GetEnvironmentVariableAsBool("CI")) + { + throw new InvalidOperationException($"Not updating file in CI: {codeFilePath.FullName}"); + } + else + { + File.WriteAllText(codeFilePath.FullName, newText); + throw new InvalidOperationException($"File outdated, commit the changes: {codeFilePath.FullName}"); + } + } + } + + // Build using CSC. + Directory.Delete(artifactsDir, recursive: true); + new DotnetCommand(Log, "run", fileName, "-bl") .WithWorkingDirectory(testInstance.Path) - .Execute(); + .Execute() + .Should().Pass() + .And.HaveStdOut($""" + {CliCommandStrings.NoBinaryLogBecauseRunningJustCsc} + Hello from {programName} + """); - var fileBasedFiles = ReadFiles(); + // Read args from csc.rsp file. + var rspFilePath = Path.Join(artifactsDir, "csc.rsp"); + var cscOnlyCallArgs = File.ReadAllLines(rspFilePath); + var cscOnlyCallArgsString = string.Join(' ', cscOnlyCallArgs); - File.WriteAllText(Path.Join(testInstance.Path, "Program.csproj"), s_consoleProject); + // Check that csc args between MSBuild run and CSC-only run are equivalent. + var normalizedCscOnlyArgs = cscOnlyCallArgs + .Select(static a => NormalizePathArg(RemoveQuotes(a))) + .ToList(); + Log.WriteLine("CSC-only args:"); + Log.WriteLine(string.Join(Environment.NewLine, normalizedCscOnlyArgs)); + Log.WriteLine("MSBuild args:"); + Log.WriteLine(string.Join(Environment.NewLine, msbuildArgsToVerify)); - var projectBasedResult = new DotnetCommand(Log, [subcommand, .. args]) - .WithWorkingDirectory(testInstance.Path) - .Execute(); + // References and targeting-pack analyzers may be in a different order (FrameworkList.xml vs. MSBuild), + // so compare them as sets. All other args must be in the same order. + var cscOnlyRefArgs = normalizedCscOnlyArgs.Where(static a => a.StartsWith("/reference:", StringComparison.Ordinal)).ToList(); + var cscOnlyAnalyzerArgs = normalizedCscOnlyArgs.Where(a => a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)).ToList(); + var cscOnlyOtherArgs = normalizedCscOnlyArgs.Where(a => !a.StartsWith("/reference:", StringComparison.Ordinal) && !(a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase))).ToList(); + var msbuildRefArgs = msbuildArgsToVerify.Where(static a => a.StartsWith("/reference:", StringComparison.Ordinal)).ToList(); + var msbuildAnalyzerArgs = msbuildArgsToVerify.Where(a => a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase)).ToList(); + var msbuildOtherArgs = msbuildArgsToVerify.Where(a => !a.StartsWith("/reference:", StringComparison.Ordinal) && !(a.StartsWith("/analyzer:", StringComparison.Ordinal) && a.Contains(NetCoreAppRefPackPath, StringComparison.OrdinalIgnoreCase))).ToList(); + cscOnlyRefArgs.Should().NotBeEmpty( + "framework references should be resolved from FrameworkList.xml"); + cscOnlyRefArgs.Should().BeEquivalentTo(msbuildRefArgs, + "the generated file might be outdated, run this test locally to regenerate it"); + cscOnlyAnalyzerArgs.Should().NotBeEmpty( + "framework analyzers should be resolved from FrameworkList.xml"); + cscOnlyAnalyzerArgs.Should().BeEquivalentTo(msbuildAnalyzerArgs, + "the generated file might be outdated, run this test locally to regenerate it"); + cscOnlyOtherArgs.Should().Equal(msbuildOtherArgs, + "the generated file might be outdated, run this test locally to regenerate it"); - var projectBasedFiles = ReadFiles(); + static CompilerCall FindCompilerCall(string binaryLogPath) + { + using var reader = BinaryLogReader.Create(binaryLogPath); + return reader.ReadAllCompilerCalls().Should().ContainSingle().Subject; + } - fileBasedResult.StdOut.Should().Be(projectBasedResult.StdOut); - fileBasedResult.StdErr!.Replace("Program.cs.csproj", "Program.csproj").Should().Be(projectBasedResult.StdErr); - fileBasedResult.ExitCode.Should().Be(projectBasedResult.ExitCode).And.Be(success ? 0 : 1); - fileBasedFiles.Should().Equal(projectBasedFiles); + static string NormalizePathArg(string arg) + { + return CSharpCompilerCommand.IsPathOption(arg, out int colonIndex) + ? string.Concat(arg.AsSpan(0, colonIndex + 1), NormalizePath(arg.Substring(colonIndex + 1))) + : NormalizePath(arg); + } - Dictionary ReadFiles() + static string NormalizePath(string path) { - var result = new DirectoryInfo(testInstance.Path) - .EnumerateFiles() - .ExceptBy(["Program.cs", "Directory.Build.props", "Program.csproj"], f => f.Name) - .ToDictionary(f => f.Name, f => File.ReadAllText(f.FullName)); + return PathUtility.GetPathWithForwardSlashes(TestPathUtility.ResolveTempPrefixLink(path)); + } - foreach (var (file, text) in result) - { - Log.WriteLine($"File '{file}':"); - Log.WriteLine(text); - File.Delete(Path.Join(testInstance.Path, file)); - } + static string RemoveQuotes(string arg) + { + return arg.Replace("\"", string.Empty); + } - return result; + static string? GetGeneratedMethodName(string assetFileName) + { + return assetFileName switch + { + $".NETCoreApp,Version=v{ToolsetInfo.CurrentTargetFrameworkVersion}.AssemblyAttributes.cs" => "AssemblyAttributes", + $"{fileName}.GlobalUsings.g.cs" => "GlobalUsings", + $"{fileName}.AssemblyInfo.cs" => "AssemblyInfo", + $"{fileName}.GeneratedMSBuildEditorConfig.editorconfig" => "GeneratedMSBuildEditorConfig", + $"{programName}{FileNameSuffixes.RuntimeConfigJson}" => "RuntimeConfig", + _ => null, + }; } } /// - /// Regression test for https://github.com/dotnet/sdk/issues/52714. - /// The virtual project's must survive GC - /// even after being evicted from MSBuild's strong cache (LRU of size N). - /// We force eviction via MSBUILDPROJECTROOTELEMENTCACHESIZE=1 - /// and trigger GC via an inline task during NuGet restore. - /// Without the fix (strong reference in VirtualProjectBuilder._projectRootElement), - /// this fails with MSB4025 "The project file could not be loaded". + /// Verifies that csc-only runs emit auxiliary files equivalent to msbuild-based runs. /// - [Fact] - public void VirtualProject_SurvivesGCDuringRestore() + [Theory] + [InlineData("Program.cs")] + [InlineData("test.cs")] + [InlineData("noext")] + public void CscVsMSBuild(string fileName) { - var testInstance = _testAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - Console.WriteLine("Hello from virtual project"); + var testInstance = _testAssetsManager.CreateTestDirectory(baseDirectory: OutOfTreeBaseDirectory); + string entryPointPath = Path.Join(testInstance.Path, fileName); + File.WriteAllText(entryPointPath, $""" + #!/test + {s_program} """); - // Directory.Build.targets that forces GC during restore, - // after SDK imports have already evicted the virtual PRE from the strong cache. - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ - - - - - - - - <_ForceGCTask /> - - - """); + string programName = Path.GetFileNameWithoutExtension(fileName); - new DotnetCommand(Log, "run", "--no-cache", "Program.cs") - // A cache size of 1 ensures the virtual PRE is evicted from the strong cache - // as soon as any SDK .targets/.props file is loaded during evaluation. - .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") + // Remove artifacts from possible previous runs of this test. + var artifactsDir = VirtualProjectBuilder.GetArtifactsPath(entryPointPath); + if (Directory.Exists(artifactsDir)) Directory.Delete(artifactsDir, recursive: true); + var artifactsBackupDir = Path.ChangeExtension(artifactsDir, ".bak"); + if (Directory.Exists(artifactsBackupDir)) Directory.Delete(artifactsBackupDir, recursive: true); + + // Build using CSC. + new DotnetCommand(Log, "run", fileName, "-bl") .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Pass() - .And.HaveStdOut("Hello from virtual project"); - } - - /// - /// Same as but for #:ref referenced projects. - /// The referenced project's must also survive GC. - /// - [Fact] - public void VirtualProject_SurvivesGCDuringRestore_RefDirective() - { - var testInstance = _testAssetsManager.CreateTestDirectory(); - - File.WriteAllText(Path.Join(testInstance.Path, "Lib.cs"), """ - #:property OutputType=Library - namespace MyLib; - public static class Greeter - { - public static string Greet() => "Hello from ref"; - } - """); - - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - #:ref Lib.cs - Console.WriteLine(MyLib.Greeter.Greet()); - """); + .And.HaveStdOut($""" + {CliCommandStrings.NoBinaryLogBecauseRunningJustCsc} + Hello from {programName} + """); - // Directory.Build.targets that forces GC during restore, - // after SDK imports have already evicted the virtual PRE from the strong cache. - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.targets"), """ - - - - - - - - <_ForceGCTask /> - - - """); + // Backup the artifacts directory. + Directory.Move(artifactsDir, artifactsBackupDir); - new DotnetCommand(Log, "run", "--no-cache", "Program.cs") - // A cache size of 1 ensures the virtual PRE is evicted from the strong cache - // as soon as any SDK .targets/.props file is loaded during evaluation. - .WithEnvironmentVariable("MSBUILDPROJECTROOTELEMENTCACHESIZE", "1") - .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + // Build using MSBuild. + new DotnetCommand(Log, "run", fileName, "-bl", "--no-cache") .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Pass() - .And.HaveStdOut("Hello from ref"); + .And.HaveStdOut($"Hello from {programName}"); + + // Check that files generated by MSBuild and CSC-only runs are equivalent. + var cscOnlyFiles = Directory.EnumerateFiles(artifactsBackupDir, "*", SearchOption.AllDirectories) + .Where(f => + Path.GetDirectoryName(f) != artifactsBackupDir && // exclude top-level marker files + Path.GetFileName(f) != programName && // binary on unix + Path.GetExtension(f) is not (".dll" or ".exe" or ".pdb")); // other binaries + bool hasErrors = false; + foreach (var cscOnlyFile in cscOnlyFiles) + { + var relativePath = Path.GetRelativePath(relativeTo: artifactsBackupDir, path: cscOnlyFile); + var msbuildFile = Path.Join(artifactsDir, relativePath); + + if (!File.Exists(msbuildFile)) + { + throw new InvalidOperationException($"File exists in CSC-only run but not in MSBuild run: {cscOnlyFile}"); + } + + var cscOnlyFileText = File.ReadAllText(cscOnlyFile); + var msbuildFileText = File.ReadAllText(msbuildFile); + if (cscOnlyFileText.ReplaceLineEndings() != msbuildFileText.ReplaceLineEndings()) + { + Log.WriteLine($"File differs between MSBuild and CSC-only runs (if this is expected, run test '{nameof(CscArguments)}' locally to re-generate the template): {cscOnlyFile}"); + const int limit = 3_000; + if (cscOnlyFileText.Length < limit && msbuildFileText.Length < limit) + { + Log.WriteLine("MSBuild file content:"); + Log.WriteLine(msbuildFileText); + Log.WriteLine("CSC-only file content:"); + Log.WriteLine(cscOnlyFileText); + } + else + { + Log.WriteLine($"MSBuild file size: {msbuildFileText.Length} chars"); + Log.WriteLine($"CSC-only file size: {cscOnlyFileText.Length} chars"); + } + hasErrors = true; + } + } + hasErrors.Should().BeFalse("some file contents do not match, see the test output for details"); } }