diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index fdf1036f70c7..80c286978143 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -203,6 +203,456 @@ public static void M() """); } + [Fact] + public void RefDirective() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + var expectedOutput = "Hello, World!"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // #:ref lib.cs should become a ProjectReference to ../lib/lib.csproj + File.ReadAllText(Path.Join(outputDirFullPath, "app", "app.csproj")) + .Should().Contain($""" + + """); + + // The referenced library should have been converted too. + var libProjectDir = Path.Join(outputDirFullPath, "lib"); + File.Exists(Path.Join(libProjectDir, "lib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libProjectDir, "lib.cs")).Should().BeTrue(); + File.ReadAllText(Path.Join(libProjectDir, "lib.csproj")) + .Should().Contain("Library"); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Fact] + public void RefDirective_Transitive_Convert() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + namespace Lib2; + public static class Helper + { + public static string Get() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class Facade + { + public static string Get() => $"from lib1 and {Lib2.Helper.Get()}"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.Facade.Get()); + """); + + var expectedOutput = "from lib1 and from lib2"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // All three projects should exist. + File.Exists(Path.Join(outputDirFullPath, "app", "app.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib1", "lib1.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib2", "lib2.csproj")).Should().BeTrue(); + + // lib1.csproj should reference lib2. + File.ReadAllText(Path.Join(outputDirFullPath, "lib1", "lib1.csproj")) + .Should().Contain($""" + + """); + + // The converted project should build and produce the same output. + new DotnetCommand(Log, "run") + .WithWorkingDirectory(Path.Join(outputDirFullPath, "app")) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + [Fact] + public void RefDirective_DuplicateFolderName() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref a/lib.cs + #:ref b/lib.cs + Console.WriteLine(A.Lib.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + + new DirectoryInfo(testInstance.Path) + .EnumerateFileSystemInfos().Select(d => d.Name).Order() + .Should().BeEquivalentTo(["a", "app.cs", "b", "Directory.Build.props"]); + } + + [Fact] + public void RefDirective_DuplicateFolderName_Transitive() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + // a/lib.cs is referenced by mid.cs + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + // mid.cs references a/lib.cs + File.WriteAllText(Path.Join(testInstance.Path, "mid.cs"), """ + #:property OutputType=Library + #:ref a/lib.cs + namespace Mid; + public static class Mid { public static string Get() => A.Lib.Get(); } + """); + + // b/lib.cs would conflict with a/lib.cs (both "lib") + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref mid.cs + #:ref b/lib.cs + Console.WriteLine(Mid.Mid.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + } + + [Fact] + public void RefDirective_DuplicateFolderName_ViaInclude() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + // a/lib.cs is referenced by the app directly + Directory.CreateDirectory(Path.Join(testInstance.Path, "a")); + File.WriteAllText(Path.Join(testInstance.Path, "a", "lib.cs"), """ + #:property OutputType=Library + namespace A; + public static class Lib { public static string Get() => "a"; } + """); + + // b/lib.cs would conflict (same name "lib") - referenced via #:include-d file + Directory.CreateDirectory(Path.Join(testInstance.Path, "b")); + File.WriteAllText(Path.Join(testInstance.Path, "b", "lib.cs"), """ + #:property OutputType=Library + namespace B; + public static class Lib { public static string Get() => "b"; } + """); + + // extra.cs is included and references b/lib.cs + File.WriteAllText(Path.Join(testInstance.Path, "extra.cs"), """ + #:ref b/lib.cs + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref a/lib.cs + #:include extra.cs + Console.WriteLine(A.Lib.Get() + B.Lib.Get()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + var duplicateTargetDirectory = Path.Join(outputDirFullPath, "lib"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format(CliCommandStrings.ProjectConvertDuplicateRefFolderName, duplicateTargetDirectory)); + + // Nothing should have been converted. + Directory.Exists(outputDirFullPath).Should().BeFalse(); + } + + /// + /// Verifies that default items (e.g., appsettings.json) in a #:ref'd file's directory + /// are copied to the converted project output directory. + /// + [Fact] + public void RefDirective_IncludedItemsCopied() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + #:property EnableDefaultNoneItems=true + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + // A non-code file next to the library that should be picked up as a default item. + File.WriteAllText(Path.Join(libDir, "data.json"), """{ "key": "value" }"""); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // The library's included item (data.json) should be copied to the ref output directory. + var libOutputDir = Path.Join(outputDirFullPath, "mylib"); + File.Exists(Path.Join(libOutputDir, "mylib.cs")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "mylib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "data.json")).Should().BeTrue(); + } + + /// + /// Verifies that --delete-source also deletes included items of #:ref'd files. + /// + [Fact] + public void RefDirective_IncludedItemsDeleted() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + #:property EnableDefaultNoneItems=true + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(libDir, "config.json"), """{ "setting": true }"""); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath, "--delete-source") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // Source files should be deleted. + File.Exists(Path.Join(testInstance.Path, "app.cs")).Should().BeFalse(); + File.Exists(Path.Join(libDir, "mylib.cs")).Should().BeFalse(); + File.Exists(Path.Join(libDir, "config.json")).Should().BeFalse(); + + // Converted files should exist. + var libOutputDir = Path.Join(outputDirFullPath, "mylib"); + File.Exists(Path.Join(libOutputDir, "mylib.cs")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "mylib.csproj")).Should().BeTrue(); + File.Exists(Path.Join(libOutputDir, "config.json")).Should().BeTrue(); + } + + /// + /// Converting one app that #:refs a library does not affect other apps that also reference the same library. + /// + [Fact] + public void RefDirective_ConvertScope() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app1.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app2.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var unrelatedDir = Path.Join(testInstance.Path, "unrelated"); + Directory.CreateDirectory(unrelatedDir); + File.WriteAllText(Path.Join(unrelatedDir, "app3.cs"), """ + #:ref ../lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + var outputDirFullPath = Path.Join(testInstance.Path, "Project"); + new DotnetCommand(Log, "project", "convert", "app1.cs", "-o", outputDirFullPath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass(); + + // app1 should be converted. + File.Exists(Path.Join(outputDirFullPath, "app1", "app1.csproj")).Should().BeTrue(); + File.Exists(Path.Join(outputDirFullPath, "lib", "lib.csproj")).Should().BeTrue(); + + // app2 and app3 should be unaffected (still exist as .cs files with their directives intact). + File.ReadAllText(Path.Join(testInstance.Path, "app2.cs")).Should().Contain("#:ref lib.cs"); + File.ReadAllText(Path.Join(unrelatedDir, "app3.cs")).Should().Contain("#:ref ../lib.cs"); + } + [Fact] public void ProjectReference_FullPath_WithVars() { @@ -1237,15 +1687,6 @@ public void Directives_IncludeExclude() { var testInstance = TestAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ - - - true - true - - - """); - VerifyConversion( baseDirectory: testInstance.Path, evaluateDirectives: true, @@ -1290,8 +1731,6 @@ public void Directives_IncludeExclude_FilesCopied() { var testInstance = TestAssetsManager.CreateTestDirectory(); File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - #:property ExperimentalFileBasedProgramEnableIncludeDirective=true - #:property ExperimentalFileBasedProgramEnableExcludeDirective=true #:include **/*.cs #:include *.json #:exclude my.json @@ -1950,7 +2389,7 @@ private static void Convert( builder.CreateProjectInstance( new ProjectCollection(), errorReporter, - out _, + project: out _, projectRootElement: out _, out directives); } @@ -2210,7 +2649,6 @@ public void DeleteSource_WithIncludeDirective_NotDeleted() // Create entry point file with #:include directive File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - #:property ExperimentalFileBasedProgramEnableIncludeDirective=true #:include Util.cs Console.WriteLine("Test"); """); @@ -2241,7 +2679,6 @@ public void DeleteSource_WithIncludeDirective_MultipleFiles() // Create entry point file with multiple #:include directives File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - #:property ExperimentalFileBasedProgramEnableIncludeDirective=true #:include Util.cs #:include Helper.cs #:include config.json @@ -2279,8 +2716,6 @@ public void DeleteSource_WithIncludeDirective_Transitive() // Create entry point file with #:include directive File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ - #:property ExperimentalFileBasedProgramEnableIncludeDirective=true - #:property ExperimentalFileBasedProgramEnableTransitiveDirectives=true #:include Util.cs Console.WriteLine("Test"); """); diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTestBase.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTestBase.cs index 91e58c83c1e0..794c62fd380b 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTestBase.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTestBase.cs @@ -176,6 +176,24 @@ internal static string DirectiveError(string path, int line, string messageForma return $"{path}({line}): {FileBasedProgramsResources.DirectiveError}: {string.Format(messageFormat, args)}"; } + internal static void EnableRefDirective(TestDirectory testInstance) + { + var propsPath = Path.Join(testInstance.Path, "Directory.Build.props"); + var propsContent = File.Exists(propsPath) ? File.ReadAllText(propsPath) : null; + if (propsContent is not null && propsContent.Contains(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective)) + { + return; + } + + File.WriteAllText(propsPath, $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + } + internal static void VerifyBinLogEvaluationDataCount(string binaryLogPath, int expectedCount) { var records = BinaryLog.ReadRecords(binaryLogPath).ToList(); diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs index ad5224a00f76..9552b75f6d17 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildCommands.cs @@ -922,5 +922,4 @@ Hello from Second Message: 'Second1' """); } - } diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs index 14ed5a0473bc..260fcffa2a97 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_BuildOptions.cs @@ -636,7 +636,6 @@ public void BinaryLog_EvaluationData() VerifyBinLogEvaluationDataCount(binaryLogPath, expectedCount: 3); } - /// /// Binary logs from our in-memory projects should have evaluation data. /// @@ -645,14 +644,6 @@ public void BinaryLog_EvaluationData_MultiFile() { var testInstance = TestAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ - - - true - - - """); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), $""" #!/usr/bin/env dotnet @@ -813,6 +804,116 @@ public void Verbosity_CompilationDiagnostics() .And.HaveStdErrContaining(CliCommandStrings.RunCommandException); } + [Fact] + public void MissingShebangWarning() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + // Single-file program without shebang should NOT produce CA2266 + // (the warning only fires when there are multiple files via #:include). + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine("hello"); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.NotHaveStdOutContaining("CA2266") + .And.HaveStdOutContaining("hello"); + + // Included file without shebang should not produce CA2266. + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), """ + class Util { public static string Greet() => "hello"; } + """); + + // Entry point with shebang and #:include — no warning. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #!/usr/bin/env dotnet + #:include Util.cs + Console.WriteLine(Util.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.NotHaveStdOutContaining("CA2266") + .And.HaveStdOutContaining("hello"); + + // Entry point without shebang and #:include — CA2266 warning expected. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #:include Util.cs + Console.WriteLine(Util.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining("warning CA2266") + .And.HaveStdOutContaining("hello"); + + // CA2266 can be suppressed via NoWarn. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #:property NoWarn=CA2266 + #:include Util.cs + Console.WriteLine(Util.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.NotHaveStdOutContaining("CA2266") + .And.HaveStdOutContaining("hello"); + } + + [Fact] + public void MissingShebangWarning_CompileItemFromDirectoryBuildProps() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + // Directory.Build.props adds a Compile item, effectively making + // the compilation multi-file (same as #:include). + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), """ + class Util { public static string Greet() => "hello"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ + + + + + + """); + + // Entry point without shebang — CA2266 warning expected + // because Directory.Build.props added another Compile item. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + Console.WriteLine(Util.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOutContaining("warning CA2266") + .And.HaveStdOutContaining("hello"); + + // Adding shebang resolves the warning. + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + #!/usr/bin/env dotnet + Console.WriteLine(Util.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("hello"); + } + /// /// File-based projects using the default SDK do not include embedded resources by default. /// @@ -900,5 +1001,4 @@ public void EmbeddedResource_AlongsideProj([CombinatorialValues("sln", "slnx", " .Should().Pass() .And.HaveStdOut(considered ? "Resource not found" : "[MyString, TestValue]"); } - } diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs index 35a845b28917..dfdec8997a9e 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_CscOnlyAndApi.cs @@ -3,7 +3,9 @@ using System.Text.Json; using Basic.CompilerLog.Util; +using Microsoft.Build.Evaluation; using Microsoft.DotNet.Cli.Commands; +using Microsoft.DotNet.Cli.Commands.NuGet; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.FileBasedPrograms; @@ -282,6 +284,50 @@ public class LibClass 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_RefDirectives() + { + 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. @@ -989,6 +1035,52 @@ public class LibClass Build(testInstance, BuildLevel.All, expectedOutput: "v2 Hello from Lib v2", programFileName: programFileName); } + /// + /// See . + /// This optimization currently does not support #:ref references and hence is disabled if those are present. + /// Analogous to . + /// + [Fact] + public void CscOnly_AfterMSBuild_RefDirectives() + { + 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"); + + programCode = programCode.Replace("Hello", "Hi"); + File.WriteAllText(programPath, programCode); + + libCode = libCode.Replace("v1", "v2"); + File.WriteAllText(libPath, libCode); + + // Cannot use CSC because we cannot detect updates in the referenced file. + Build(testInstance, BuildLevel.All, expectedOutput: "Hi v2"); + } + /// /// See . /// If users have more complex build customizations, they can opt out of the optimization. @@ -1325,14 +1417,6 @@ public void Api_Evaluation() { var testInstance = TestAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ - - - true - - - """); - var programPath = Path.Join(testInstance.Path, "A.cs"); File.WriteAllText(programPath, """ #:property P1=cs @@ -1603,6 +1687,57 @@ public void Api_RunCommand() """); } + [Fact] + public void Api_VirtualProjectBuilder_CreateProjectRootElement() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + var libDir = Path.Join(testInstance.Path, "Lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "Lib.csproj"), $""" + + + {ToolsetInfo.CurrentTargetFramework} + + + """); + + File.WriteAllText(Path.Join(libDir, "Lib.cs"), """ + namespace Lib; + public class LibClass + { + public static string GetMessage() => "Hello from Lib"; + } + """); + + var appDir = Path.Join(testInstance.Path, "App"); + Directory.CreateDirectory(appDir); + + var appPath = Path.Join(appDir, "Program.cs"); + File.WriteAllText(appPath, """ + #:project ../$(LibProjectName) + #:property LibProjectName=Lib + Console.WriteLine(Lib.LibClass.GetMessage()); + """); + + using var projectCollection = new ProjectCollection(); + var projectRootElement = NuGetVirtualProjectBuilder.Instance.CreateProjectRootElement(appPath, projectCollection); + + var xml = projectRootElement.RawXml; + Log.WriteLine(xml); + + xml.Should() + // directives are evaluated + .Contain("""""".Replace('\\', Path.DirectorySeparatorChar)) + // it's the virtual project + .And.Contain("true") + // correct target framework is used + .And.Contain($"{ToolsetInfo.CurrentTargetFramework}"); + + projectRootElement.FullPath.Should().Be(VirtualProjectBuilder.GetVirtualProjectPath(appPath)); + } + [Theory, CombinatorialData] public void EntryPointFilePath(bool cscOnly) { @@ -1833,6 +1968,109 @@ Dictionary ReadFiles() } } + /// + /// 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 . diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs index 06ff227764d0..95a32f497891 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_Directives.cs @@ -314,6 +314,687 @@ public void ProjectReference_Duplicate(string? subdir) .And.HaveStdOut("Hello"); } + [Fact] + public void RefDirective() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + [Fact] + public void RefDirective_Subdirectory() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + /// + /// Analogous to but for #:ref. + /// + [Theory] + [InlineData(null)] + [InlineData("app")] + public void RefDirective_Errors(string? subdir) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + // Missing name. + File.WriteAllText(filePath, """ + #:ref + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.MissingDirectiveName, "ref")); + + // File does not exist. + File.WriteAllText(filePath, """ + #:ref nonexistent.cs + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, subdir, "nonexistent.cs")))); + } + + /// + /// Verifies that #:ref produces a metadata (assembly) reference, + /// meaning internal members are not accessible unless InternalsVisibleTo is used. + /// + [Fact] + public void RefDirective_InternalsNotAccessible() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class PublicClass + { + public static string PublicMethod() => "public"; + internal static string InternalMethod() => "internal"; + } + internal static class InternalClass + { + public static string Method() => "internal class"; + } + """); + + // Accessing internal member should fail. + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.PublicClass.InternalMethod()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining("error CS"); + + // Accessing public member should succeed. + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + Console.WriteLine(MyLib.PublicClass.PublicMethod()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("public"); + } + + /// + /// Verifies transitive #:ref references work: app.cs → lib1.cs → lib2.cs. + /// + [Fact] + public void RefDirective_Transitive() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + namespace Lib2; + public static class Base + { + public static string Value() => "from lib2"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class Middle + { + public static string Value() => $"from lib1 and {Lib2.Base.Value()}"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.Middle.Value()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("from lib1 and from lib2"); + } + + /// + /// #:ref with various path formats (forward slashes, backslashes, MSBuild properties, parent dirs). + /// Analogous to . + /// + [Theory] + [InlineData("../Lib/lib.cs")] + [InlineData(@"..\Lib\lib.cs")] + [InlineData("$(MSBuildProjectDirectory)/../$(LibDirName)/lib.cs")] + [InlineData(@"$(MSBuildProjectDirectory)\..\Lib\lib.cs")] + public void RefDirective_PathFormats(string arg) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + var libDir = Path.Join(testInstance.Path, "Lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => $"Hello, {name}!"; + } + """); + + var appDir = Path.Join(testInstance.Path, "App"); + Directory.CreateDirectory(appDir); + + File.WriteAllText(Path.Join(appDir, "app.cs"), $""" + #:ref {arg} + #:property LibDirName=Lib + Console.WriteLine(MyLib.Greeter.Greet("World")); + """); + + var expectedOutput = "Hello, World!"; + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(appDir) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + + // Running from a different working directory shouldn't affect handling of the relative paths. + new DotnetCommand(Log, "run", "App/app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); + } + + /// + /// #:ref duplicate detection. + /// Analogous to . + /// + [Theory] + [InlineData(null)] + [InlineData("app")] + public void RefDirective_Duplicate(string? subdir) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + File.WriteAllText(Path.Join(testInstance.Path, subdir, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 2, FileBasedProgramsResources.DuplicateDirective, "#:ref lib.cs")); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref ./lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate ref + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + + File.WriteAllText(filePath, """ + #:ref lib.cs + #:ref $(MSBuildProjectDirectory)/lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate ref + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + } + + /// + /// #:ref is an experimental feature that must be opted into. + /// Analogous to . + /// + [Fact] + public void RefDirective_FeatureFlag() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + var libPath = Path.Join(testInstance.Path, "lib.cs"); + File.WriteAllText(libPath, """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + var programPath = Path.Join(testInstance.Path, "Program.cs"); + File.WriteAllText(programPath, """ + #:ref lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErr($""" + {DirectiveError(programPath, 1, Resources.ExperimentalFeatureDisabled, CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective)} + + {CliCommandStrings.RunCommandException} + """); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello!"); + } + + /// + /// Combining #:ref and #:include in the same file-based app. + /// + [Fact] + public void RefDirective_WithInclude() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #!/usr/bin/env dotnet + #:property OutputType=Library + #:include LibHelper.cs + #:include LibFormatter.cs + namespace MyLib; + public static class Greeter + { + public static string Greet(string name) => LibFormatter.Format(LibHelper.Prefix, name); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "LibHelper.cs"), """ + namespace MyLib; + public static class LibHelper + { + public static string Prefix => "Hello"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "LibFormatter.cs"), """ + namespace MyLib; + public static class LibFormatter + { + public static string Format(string prefix, string name) => $"{prefix}, {name}!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), """ + static class Util + { + public static string GetName() => "World"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:ref lib.cs + #:include Util.cs + Console.WriteLine(MyLib.Greeter.Greet(Util.GetName())); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello, World!"); + } + + /// + /// A #:ref library can target a different framework (e.g., netstandard2.0) + /// than the referencing app (net10.0). + /// + [Fact] + public void RefDirective_DifferentTargetFramework() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + #:property TargetFramework=netstandard2.0 + #:property LangVersion=latest + #:property ImplicitUsings=disable + #:property PublishAot=false + namespace MyLib; + public static class Greeter + { + #if NETSTANDARD2_0 + public static string Greet() => "Hello from netstandard2.0!"; + #else + public static string Greet() => "Hello from other!"; + #endif + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + #if NET10_0_OR_GREATER + Console.WriteLine("App is net10.0+: " + MyLib.Greeter.Greet()); + #else + Console.WriteLine("App is older: " + MyLib.Greeter.Greet()); + #endif + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("App is net10.0+: Hello from netstandard2.0!"); + } + + /// + /// #:ref *.cs does not expand globs — it looks for a literal file named *.cs. + /// + [Fact] + public void RefDirective_Glob() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + var filePath = Path.Join(testInstance.Path, "app.cs"); + File.WriteAllText(filePath, """ + #:ref *.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, Path.Join(testInstance.Path, "*.cs")))); + } + + /// + /// Verifies that cyclic #:ref references (lib1 → lib2 → lib1) do not cause an infinite loop. + /// + [Fact] + public void RefDirective_Cycle() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + EnableRefDirective(testInstance); + + File.WriteAllText(Path.Join(testInstance.Path, "lib1.cs"), """ + #:property OutputType=Library + #:ref lib2.cs + namespace Lib1; + public static class C1 { public static string Get() => "lib1"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib2.cs"), """ + #:property OutputType=Library + #:ref lib1.cs + namespace Lib2; + public static class C2 { public static string Get() => "lib2"; } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib1.cs + Console.WriteLine(Lib1.C1.Get()); + """); + + // Should not hang. The cycle is broken by processedFiles deduplication. + // error NU1108: Cycle detected. + // error NU1108: lib1 -> lib2 -> lib1. + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining("error NU1108"); + } + + /// + /// Two #:include'd files each have #:ref to the same library. + /// The deduplication via processedFiles should ensure the library is only processed once. + /// + [Fact] + public void RefDirective_DuplicateRefFromIncludedFiles() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "helper1.cs"), """ + #:ref lib.cs + static class Helper1 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "helper2.cs"), """ + #:ref lib.cs + static class Helper2 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:include helper1.cs + #:include helper2.cs + Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello! Hello!"); + } + + /// + /// Two #:include'd files in different directories each have #:ref to the same library + /// using different relative paths. Deduplication via processedFiles uses the resolved (absolute) path, + /// so the library is only processed once. + /// + [Fact] + public void RefDirective_DuplicateRefFromIncludedFiles_Subdirectories() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + // lib.cs is in the root directory. + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + // helper1.cs is in sub1/, refers to lib.cs via ../lib.cs. + var sub1 = Path.Join(testInstance.Path, "sub1"); + Directory.CreateDirectory(sub1); + File.WriteAllText(Path.Join(sub1, "helper1.cs"), """ + #:ref ../lib.cs + static class Helper1 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + // helper2.cs is in sub2/nested/, refers to lib.cs via ../../lib.cs (different relative path, same resolved path). + var sub2 = Path.Join(testInstance.Path, "sub2", "nested"); + Directory.CreateDirectory(sub2); + File.WriteAllText(Path.Join(sub2, "helper2.cs"), """ + #:ref ../../lib.cs + static class Helper2 + { + public static string Get() => MyLib.Greeter.Greet(); + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #!/usr/bin/env dotnet + #:include sub1/helper1.cs + #:include sub2/nested/helper2.cs + Console.WriteLine(Helper1.Get() + " " + Helper2.Get()); + """); + + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello! Hello!"); + } + + /// + /// Both #:include and #:ref pointing at the same file. + /// The file ends up both compiled into the current assembly and referenced as a separate assembly. + /// This is expected to produce a compilation error (duplicate type definitions). + /// + [Fact] + public void RefDirective_IncludeAndRefSameFile() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), $""" + + + <{CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective}>true + + + """); + + File.WriteAllText(Path.Join(testInstance.Path, "lib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello!"; + } + """); + + File.WriteAllText(Path.Join(testInstance.Path, "app.cs"), """ + #:ref lib.cs + #:include lib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """); + + // The #:include brings in lib.cs's #:property OutputType=Library, making the app a library. + // error CS8805: Program using top-level statements must be an executable. + new DotnetCommand(Log, "run", "app.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdOutContaining("error CS8805"); + } + [Theory, CombinatorialData] public void IncludeDirective( [CombinatorialValues("Util.cs", "**/*.cs", "**/*.$(MyProp1)")] string includePattern, @@ -730,31 +1411,6 @@ class UtilClass Build(testInstance, BuildLevel.All, expectedOutput: expectedOutput, workDir: appDir); } - [Fact] - public void IncludeDirective_FeatureFlags() - { - var testInstance = TestAssetsManager.CreateTestDirectory(); - - var programPath = Path.Join(testInstance.Path, "Program.cs"); - File.WriteAllText(programPath, $""" - #!/usr/bin/env dotnet - #:include *.cs - {s_programDependingOnUtil} - """); - - var utilPath = Path.Join(testInstance.Path, "Util.cs"); - File.WriteAllText(utilPath, $""" - #:exclude Other.cs - {s_util} - """); - - new DotnetCommand(Log, "run", "Program.cs") - .WithWorkingDirectory(testInstance.Path) - .Execute() - .Should().Pass() - .And.HaveStdOut("Hello, String from Util"); - } - [Fact] public void IncludeDirective_CustomMapping() { @@ -762,6 +1418,7 @@ public void IncludeDirective_CustomMapping() var programPath = Path.Join(testInstance.Path, "Program.cs"); File.WriteAllText(programPath, $""" + #!/usr/bin/env dotnet #:property FileBasedProgramsItemMapping=.json=Content #:include *.cs {s_programDependingOnUtil} @@ -775,12 +1432,13 @@ public void IncludeDirective_CustomMapping() .Execute() .Should().Fail() .And.HaveStdErr($""" - {DirectiveError(programPath, 2, FileBasedProgramsResources.IncludeOrExcludeDirectiveUnknownFileType, "#:include", ".json")} + {DirectiveError(programPath, 3, FileBasedProgramsResources.IncludeOrExcludeDirectiveUnknownFileType, "#:include", ".json")} {CliCommandStrings.RunCommandException} """); File.WriteAllText(programPath, $""" + #!/usr/bin/env dotnet #:property FileBasedProgramsItemMapping=.cs=Content #:include *.cs {s_programDependingOnUtil} @@ -1024,5 +1682,4 @@ public void UserSecrets(bool useIdArg, string? userSecretsId) MySecret=MyValue (JsonConfigurationProvider for 'secrets.json' (Optional)) """); } - } diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests_General.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests_General.cs index 1e2b5664f9c7..27ea8456623a 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests_General.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests_General.cs @@ -453,6 +453,61 @@ public class LibClass .And.HaveStdErrContaining(errorParts[1]); } + /// + /// dotnet run - with #:ref uses $(MSBuildStartupDirectory) to resolve paths. + /// Relative paths don't work from stdin since the file is in an isolated temp directory. + /// Analogous to . + /// + [Fact] + public void ReadFromStdin_RefDirective() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + var libDir = Path.Join(testInstance.Path, "lib"); + Directory.CreateDirectory(libDir); + + File.WriteAllText(Path.Join(libDir, "mylib.cs"), """ + #:property OutputType=Library + namespace MyLib; + public static class Greeter + { + public static string Greet() => "Hello from lib!"; + } + """); + + var appDir = Path.Join(testInstance.Path, "app"); + Directory.CreateDirectory(appDir); + + new DotnetCommand(Log, "run", "-") + .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithStandardInput(""" + #:ref $(MSBuildStartupDirectory)/../lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello from lib!"); + + // Relative paths are resolved from the isolated temp directory, hence they don't work. + + var errorParts = DirectiveError("app.cs", 1, FileBasedProgramsResources.InvalidRefDirective, + string.Format(FileBasedProgramsResources.CouldNotFindRefFile, "{}")).Split("{}"); + errorParts.Should().HaveCount(2); + + new DotnetCommand(Log, "run", "-") + .WithWorkingDirectory(appDir) + .WithEnvironmentVariable(CSharpDirective.Ref.ExperimentalFileBasedProgramEnableRefDirective, "true") + .WithStandardInput(""" + #:ref ../lib/mylib.cs + Console.WriteLine(MyLib.Greeter.Greet()); + """) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(errorParts[0]) + .And.HaveStdErrContaining(errorParts[1]); + } + [Fact] public void ReadFromStdin_NoBuild() { @@ -546,7 +601,8 @@ public void ProjectPath_Exists() .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Pass() - .And.HaveStdOutContaining(""" + .And.NotHaveStdErr() + .And.HaveStdOut(""" echo args:./App.csproj Hello from App """); @@ -565,9 +621,8 @@ public void ProjectInCurrentDirectory_NoRunVerb() .WithWorkingDirectory(Path.Join(testInstance.Path, "proj")) .Execute() .Should().Pass() - .And.HaveStdOutContaining(""" - Hello from Program - """); + .And.NotHaveStdErr() + .And.HaveStdOut("Hello from Program"); } [Fact] @@ -583,11 +638,263 @@ public void ProjectInCurrentDirectory_FileOption() .WithWorkingDirectory(Path.Join(testInstance.Path, "proj")) .Execute() .Should().Pass() - .And.HaveStdOutContaining(""" - Hello from Program + .And.NotHaveStdErr() + .And.HaveStdOut("Hello from Program"); + } + + /// + /// dotnet run --project App.csproj Program.cs does not warn + /// because --project was explicitly specified. + /// + [Fact] + public void ProjectInCurrentDirectory_ProjectOption_NoWarning() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "--project", "App.csproj", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.NotHaveStdErr() + .And.HaveStdOut(""" + echo args:Program.cs + Hello from App """); } + /// + /// dotnet run file.cs in a directory with a project file warns + /// because file.cs is passed as an application argument to the project instead of running as a file-based program. + /// + [Fact] + public void ProjectInCurrentDirectory_Warns() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:Program.cs + Hello from App + """) + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.RunCommandWarningFileArgumentPassedToProject, + "Program.cs", + Path.Join(testInstance.Path, "App.csproj"))); + } + + /// + /// dotnet run nonexistent.cs in a directory with a project file warns + /// even though the file does not exist, because the .cs extension suggests it was intended as a file-based program. + /// + [Fact] + public void ProjectInCurrentDirectory_NonExistentCsFile_Warns() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "nonexistent.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:nonexistent.cs + Hello from App + """) + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.RunCommandWarningCsFileArgumentPassedToProject, + "nonexistent.cs", + Path.Join(testInstance.Path, "App.csproj"))); + } + + /// + /// dotnet run -- file.cs in a directory with a project file does not warn + /// because -- signals that the arguments are intentional. + /// + [Fact] + public void ProjectInCurrentDirectory_DoubleDash_NoWarning() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "--", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:Program.cs + Hello from App + """) + .And.NotHaveStdErr(); + } + + /// + /// dotnet run file.cs -- other still warns because file.cs appears before --. + /// + [Fact] + public void ProjectInCurrentDirectory_DoubleDashAfterFile_Warns() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "Program.cs", "--", "otherArg") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:Program.cs;otherArg + Hello from App + """) + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.RunCommandWarningFileArgumentPassedToProject, + "Program.cs", + Path.Join(testInstance.Path, "App.csproj"))); + } + + /// + /// dotnet run someArg file.cs in a directory with a project warns + /// when an unrecognized argument prevents file.cs from being treated as a file-based program entry point. + /// + [Fact] + public void ProjectInCurrentDirectory_UnrecognizedArg_Warns() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "someArg", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:someArg;Program.cs + Hello from App + """) + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.RunCommandWarningFileArgumentPassedToProject, + "Program.cs", + Path.Join(testInstance.Path, "App.csproj"))); + } + + /// + /// dotnet run -c Release Program.cs in a directory with a project warns because + /// known options like -c don't suppress the warning; only --project, --file, or -- do. + /// + [Fact] + public void ProjectInCurrentDirectory_KnownOption_Warns() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "-c", "Release", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:Program.cs + Hello from App + Release config + """) + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.RunCommandWarningFileArgumentPassedToProject, + "Program.cs", + Path.Join(testInstance.Path, "App.csproj"))); + } + + /// + /// dotnet run someArg -- file.cs does not warn because the .cs file is after --. + /// + [Fact] + public void ProjectInCurrentDirectory_UnrecognizedArg_DoubleDash_NoWarning() + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + File.WriteAllText(Path.Join(testInstance.Path, "App.csproj"), s_consoleProject); + + new DotnetCommand(Log, "run", "someArg", "--", "Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(""" + echo args:someArg;Program.cs + Hello from App + """) + .And.NotHaveStdErr(); + } + + /// + /// dotnet build someArg Program.cs warns because 'Program.cs' is a valid file-based entry point + /// but additional positional arguments cause it to fall back to MSBuild. + /// + [Theory] + [InlineData("build", "someArg", "Program.cs")] + [InlineData("clean", "someArg", "Program.cs")] + [InlineData("publish", "someArg", "Program.cs")] + [InlineData("build", "Program.cs", "-consoleLoggerParameters:NoSummary")] + public void ExtraArgWithFileEntryPoint_Warns(string command, string arg1, string arg2) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, command, arg1, arg2) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.WarningFileArgumentPassedToMSBuild, + "Program.cs", + command)); + } + + /// + /// dotnet build nonexistent.cs warns because the .cs extension suggests it was intended as a file-based program. + /// + [Theory] + [InlineData("build")] + [InlineData("clean")] + [InlineData("publish")] + public void NonExistentCsFile_Warns(string command) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + + new DotnetCommand(Log, command, "nonexistent.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(string.Format( + CliCommandStrings.WarningCsFileArgumentPassedToMSBuild, + "nonexistent.cs", + command)); + } + + /// + /// dotnet build --no-incremental Program.cs is handled as file-based (known option + single positional arg) and does not warn. + /// + [Theory] + [InlineData("Program.cs")] + [InlineData("--no-incremental", "Program.cs")] + public void SingleFileEntryPoint_NoWarning(params string[] extraArgs) + { + var testInstance = TestAssetsManager.CreateTestDirectory(); + File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), s_program); + + new DotnetCommand(Log, ["build", .. extraArgs]) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.NotHaveStdErr(); + } + /// /// When a file is not a .cs file, we probe the first characters of the file for #!, and /// execute as a single file program if we find them. @@ -789,9 +1096,6 @@ public static class B """); File.WriteAllText(Path.Join(testInstance.Path, "Directory.Build.props"), """ - - true -