From 86d4207cb7d2494a6ba2d06826aadb6e168618c7 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Thu, 11 Jun 2026 19:46:14 +0200 Subject: [PATCH 01/14] Migrate Microsoft.DotNet.HotReload.Watch.Aspire.Tests to MSTest.Sdk on MTP This is a pathfinder PR for migrating the test suite to MSTest on Microsoft.Testing.Platform (MTP). Microsoft.DotNet.HotReload.Watch.Aspire.Tests was chosen because it has no dependency on the shared Microsoft.NET.TestFramework (which is xUnit-coupled and referenced by ~57 of 78 test projects), so it can migrate in isolation without unblocking dependents first. Changes: * global.json: add MSTest.Sdk 4.3.0-preview.26307.5 to msbuild-sdks. * test/Directory.Build.targets: gate the xUnit defaults (TestRunnerName=XUnitV3, Using Include=Xunit, etc.) behind $(UseMSTestSdk) != true, so MSTest.Sdk projects opt out cleanly. * test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests: - csproj now uses Sdk="MSTest.Sdk", sets UseMSTestSdk=true, references AwesomeAssertions and only the Watch.Aspire project. MTP is on by default via MSTest.Sdk (EnableMSTestRunner + TestingPlatformDotnetTestSupport). - All 4 unit-test files converted from xUnit to MSTest attributes/asserts ([Fact]/[Theory] -> [TestMethod]/[DataRow], Assert.* equivalents, Assert.IsInstanceOfType, Assert.HasCount, Assert.IsEmpty). - Local AssertEx.SequenceEqual helper replaces the xUnit-coupled one from HotReload.Test.Utilities. * Move the 2 integration tests (AspireLauncherTests + PipeUtilities) to test/dotnet-watch.Tests/Aspire/ so the Aspire.Tests project stays a pure MSTest unit-test project. They keep xUnit because they depend on WatchSdkTest, WatchableApp, [PlatformSpecificFact], ITestOutputHelper and TestAssets from Microsoft.NET.TestFramework. AspireLauncherTests was renamed to AspireLauncherIntegrationTests to reflect its new role. * src/Dotnet.Watch/Watch.Aspire/Properties/AssemblyInfo.cs: grant InternalsVisibleTo to dotnet-watch.Tests (needed by PipeUtilities, which uses internal WatchStatusEvent). * test/dotnet-watch.Tests/dotnet-watch.Tests.csproj: add ProjectReference to Watch.Aspire (ExcludeAssets=Runtime) so the moved integration tests compile. Verification: * Microsoft.DotNet.HotReload.Watch.Aspire.Tests builds with MSTest.Sdk and all 58 unit tests pass under MTP (705 ms). * test/dotnet-watch.Tests builds successfully with the moved files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Watch.Aspire/Properties/AssemblyInfo.cs | 1 + .../AspireHostLauncherCliTests.cs | 111 ++++++------ .../AspireHostLauncherTests.cs | 57 +++--- .../AspireResourceLauncherCliTests.cs | 163 +++++++++--------- .../AspireServerLauncherCliTests.cs | 87 +++++----- .../AssertEx.cs | 30 ++++ ...DotNet.HotReload.Watch.Aspire.Tests.csproj | 16 +- .../Aspire/AspireLauncherIntegrationTests.cs} | 2 +- .../Aspire}/PipeUtilities.cs | 0 .../dotnet-watch.Tests.csproj | 2 + 10 files changed, 255 insertions(+), 214 deletions(-) create mode 100644 test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs rename test/{Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs => dotnet-watch.Tests/Aspire/AspireLauncherIntegrationTests.cs} (98%) rename test/{Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities => dotnet-watch.Tests/Aspire}/PipeUtilities.cs (100%) diff --git a/src/Dotnet.Watch/Watch.Aspire/Properties/AssemblyInfo.cs b/src/Dotnet.Watch/Watch.Aspire/Properties/AssemblyInfo.cs index 74a1e15ab7a8..3d48d3fc6f5e 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Properties/AssemblyInfo.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Properties/AssemblyInfo.cs @@ -4,4 +4,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.DotNet.HotReload.Watch.Aspire.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("dotnet-watch.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs index b7d9c7f69059..23edc5e39889 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs @@ -1,145 +1,146 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch.UnitTests; +[TestClass] public class AspireHostLauncherCliTests { - [Fact] + [TestMethod] public void RequiredSdkOption() { // --sdk option is missing var args = new[] { "host", "--entrypoint", "proj", "a", "b" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void RequiredEntryPointOption() { // --entrypoint option is missing var args = new[] { "host", "--sdk", "sdk", "--verbose" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void ProjectAndSdkPaths() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myproject.csproj" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); - Assert.True(launcher.EntryPoint.IsProjectFile); - Assert.Equal("myproject.csproj", launcher.EntryPoint.PhysicalPath); - Assert.Empty(launcher.ApplicationArguments); - Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.IsTrue(launcher.EntryPoint.IsProjectFile); + Assert.AreEqual("myproject.csproj", launcher.EntryPoint.PhysicalPath); + Assert.IsEmpty(launcher.ApplicationArguments); + Assert.AreEqual(LogLevel.Information, launcher.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void FilePath() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "file.cs" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); - Assert.False(launcher.EntryPoint.IsProjectFile); - Assert.Equal("file.cs", launcher.EntryPoint.EntryPointFilePath); - Assert.Empty(launcher.ApplicationArguments); - Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.IsFalse(launcher.EntryPoint.IsProjectFile); + Assert.AreEqual("file.cs", launcher.EntryPoint.EntryPointFilePath); + Assert.IsEmpty(launcher.ApplicationArguments); + Assert.AreEqual(LogLevel.Information, launcher.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void ApplicationArguments() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose", "a", "b" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); - Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void VerboseOption() { // With verbose flag var argsVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose" }; - var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); - Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); // Without verbose flag var argsNotVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); - Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void QuietOption() { // With quiet flag var argsQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet" }; - var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); - Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); // Without quiet flag var argsNotQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); - Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void NoLaunchProfileOption() { // With no-launch-profile flag var argsNoProfile = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--no-launch-profile" }; - var launcherNoProfile = Assert.IsType(AspireLauncher.TryCreate(argsNoProfile)); - Assert.False(launcherNoProfile.LaunchProfileName.HasValue); + var launcherNoProfile = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); + Assert.IsFalse(launcherNoProfile.LaunchProfileName.HasValue); // Without no-launch-profile flag var argsDefault = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherDefault = Assert.IsType(AspireLauncher.TryCreate(argsDefault)); - Assert.True(launcherDefault.LaunchProfileName.HasValue); - Assert.Null(launcherDefault.LaunchProfileName.Value); + var launcherDefault = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsDefault)); + Assert.IsTrue(launcherDefault.LaunchProfileName.HasValue); + Assert.IsNull(launcherDefault.LaunchProfileName.Value); } - [Fact] + [TestMethod] public void LaunchProfileOption() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.True(launcher.LaunchProfileName.HasValue); - Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.IsTrue(launcher.LaunchProfileName.HasValue); + Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } - [Fact] + [TestMethod] public void ConflictingOptions() { // Cannot specify both --quiet and --verbose var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet", "--verbose" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void EntryPoint_MultipleValues() { // EntryPoint option should only accept one value; extra values become application arguments var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj1", "proj2" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath); AssertEx.SequenceEqual(["proj2"], launcher.ApplicationArguments); } - [Fact] + [TestMethod] public void AllOptionsSet() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); - Assert.True(launcher.EntryPoint.IsProjectFile); - Assert.Equal("myapp.csproj", launcher.EntryPoint.PhysicalPath); - Assert.Equal("sdk", launcher.EnvironmentOptions.SdkDirectory); - Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); - Assert.False(launcher.LaunchProfileName.HasValue); + Assert.IsTrue(launcher.EntryPoint.IsProjectFile); + Assert.AreEqual("myapp.csproj", launcher.EntryPoint.PhysicalPath); + Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); + Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.IsFalse(launcher.LaunchProfileName.HasValue); AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], launcher.ApplicationArguments); } -} +} \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs index 94abac0ac2ca..33cf07e5aa9e 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; @@ -6,6 +6,7 @@ namespace Microsoft.DotNet.Watch.UnitTests; +[TestClass] public class AspireHostLauncherTests { private static AspireHostLauncher CreateLauncher( @@ -33,13 +34,13 @@ private static AspireHostLauncher CreateLauncher( private static void AssertCommonProperties(ProjectOptions options, AspireHostLauncher launcher) { - Assert.True(options.IsMainProject); - Assert.Equal("run", options.Command); - Assert.Equal(launcher.EntryPoint, options.Representation); - Assert.Empty(options.LaunchEnvironmentVariables); + Assert.IsTrue(options.IsMainProject); + Assert.AreEqual("run", options.Command); + Assert.AreEqual(launcher.EntryPoint, options.Representation); + Assert.IsEmpty(options.LaunchEnvironmentVariables); } - [Fact] + [TestMethod] public void GetProjectOptions_ProjectFile_UsesProjectFlag() { var launcher = CreateLauncher("myapp.csproj"); @@ -47,11 +48,11 @@ public void GetProjectOptions_ProjectFile_UsesProjectFlag() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.False(options.LaunchProfileName.HasValue); + Assert.IsFalse(options.LaunchProfileName.HasValue); AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_EntryPointFile_UsesFileFlag() { var launcher = CreateLauncher("Program.cs"); @@ -59,11 +60,11 @@ public void GetProjectOptions_EntryPointFile_UsesFileFlag() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.False(options.LaunchProfileName.HasValue); + Assert.IsFalse(options.LaunchProfileName.HasValue); AssertEx.SequenceEqual(["--file", "Program.cs", "--no-launch-profile"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_WithLaunchProfile_AddsLaunchProfileArguments() { var launcher = CreateLauncher("myapp.csproj", launchProfileName: "MyProfile"); @@ -71,12 +72,12 @@ public void GetProjectOptions_WithLaunchProfile_AddsLaunchProfileArguments() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.True(options.LaunchProfileName.HasValue); - Assert.Equal("MyProfile", options.LaunchProfileName.Value); + Assert.IsTrue(options.LaunchProfileName.HasValue); + Assert.AreEqual("MyProfile", options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "MyProfile"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_NoLaunchProfile_AddsNoLaunchProfileFlag() { var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue); @@ -84,11 +85,11 @@ public void GetProjectOptions_NoLaunchProfile_AddsNoLaunchProfileFlag() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.False(options.LaunchProfileName.HasValue); + Assert.IsFalse(options.LaunchProfileName.HasValue); AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_NullLaunchProfile_UsesDefault() { // null value (HasValue=true) means use default launch profile - no --launch-profile or --no-launch-profile flag @@ -97,12 +98,12 @@ public void GetProjectOptions_NullLaunchProfile_UsesDefault() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.True(options.LaunchProfileName.HasValue); - Assert.Null(options.LaunchProfileName.Value); + Assert.IsTrue(options.LaunchProfileName.HasValue); + Assert.IsNull(options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--project", "myapp.csproj"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_WithApplicationArguments_AppendsArguments() { var launcher = CreateLauncher("myapp.csproj", launchProfileName: "Profile", applicationArguments: ["arg1", "arg2"]); @@ -110,12 +111,12 @@ public void GetProjectOptions_WithApplicationArguments_AppendsArguments() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.True(options.LaunchProfileName.HasValue); - Assert.Equal("Profile", options.LaunchProfileName.Value); + Assert.IsTrue(options.LaunchProfileName.HasValue); + Assert.AreEqual("Profile", options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "Profile", "arg1", "arg2"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_SetsCustomWorkingDirectory() { var launcher = CreateLauncher("myapp.csproj", workingDirectory: "/custom/path"); @@ -123,10 +124,10 @@ public void GetProjectOptions_SetsCustomWorkingDirectory() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.Equal("/custom/path", options.WorkingDirectory); + Assert.AreEqual("/custom/path", options.WorkingDirectory); } - [Fact] + [TestMethod] public void GetProjectOptions_EntryPointFile_WithLaunchProfileAndArguments() { var launcher = CreateLauncher("Program.cs", launchProfileName: "Dev", applicationArguments: ["--port", "8080"]); @@ -134,12 +135,12 @@ public void GetProjectOptions_EntryPointFile_WithLaunchProfileAndArguments() var options = launcher.GetHostProjectOptions()!; AssertCommonProperties(options, launcher); - Assert.True(options.LaunchProfileName.HasValue); - Assert.Equal("Dev", options.LaunchProfileName.Value); + Assert.IsTrue(options.LaunchProfileName.HasValue); + Assert.AreEqual("Dev", options.LaunchProfileName.Value); AssertEx.SequenceEqual(["--file", "Program.cs", "--launch-profile", "Dev", "--port", "8080"], options.CommandArguments); } - [Fact] + [TestMethod] public void GetProjectOptions_NoLaunchProfile_WithApplicationArguments() { var launcher = CreateLauncher("myapp.csproj", launchProfileName: Optional.NoValue, applicationArguments: ["--urls", "http://localhost:5000"]); @@ -147,7 +148,7 @@ public void GetProjectOptions_NoLaunchProfile_WithApplicationArguments() var options = launcher.GetHostProjectOptions(); AssertCommonProperties(options, launcher); - Assert.False(options.LaunchProfileName.HasValue); + Assert.IsFalse(options.LaunchProfileName.HasValue); AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile", "--urls", "http://localhost:5000"], options.CommandArguments); } -} +} \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs index d0151c2102fb..ef5a3347ddde 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; @@ -6,175 +6,176 @@ namespace Microsoft.DotNet.Watch.UnitTests; +[TestClass] public class AspireResourceLauncherCliTests { - [Fact] + [TestMethod] public void RequiredServerOption() { // --server option is missing var args = new[] { "resource", "--entrypoint", "proj" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void RequiredEntryPointOption() { // --entrypoint option is missing var args = new[] { "resource", "--server", "pipe1" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void MinimalRequiredOptions() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj.csproj" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("pipe1", launcher.ServerPipeName); - Assert.Equal("proj.csproj", launcher.EntryPoint); - Assert.Empty(launcher.ApplicationArguments); - Assert.Empty(launcher.EnvironmentVariables); - Assert.True(launcher.LaunchProfileName.HasValue); - Assert.Null(launcher.LaunchProfileName.Value); - Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("pipe1", launcher.ServerPipeName); + Assert.AreEqual("proj.csproj", launcher.EntryPoint); + Assert.IsEmpty(launcher.ApplicationArguments); + Assert.IsEmpty(launcher.EnvironmentVariables); + Assert.IsTrue(launcher.LaunchProfileName.HasValue); + Assert.IsNull(launcher.LaunchProfileName.Value); + Assert.AreEqual(LogLevel.Information, launcher.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void ApplicationArguments() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "a", "b" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); } - [Fact] + [TestMethod] public void EnvironmentOption_SingleVariable() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=value" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Single(launcher.EnvironmentVariables); - Assert.Equal("value", launcher.EnvironmentVariables["KEY"]); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.HasCount(1, launcher.EnvironmentVariables); + Assert.AreEqual("value", launcher.EnvironmentVariables["KEY"]); } - [Fact] + [TestMethod] public void EnvironmentOption_MultipleVariables() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY1=val1", "-e", "KEY2=val2" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal(2, launcher.EnvironmentVariables.Count); - Assert.Equal("val1", launcher.EnvironmentVariables["KEY1"]); - Assert.Equal("val2", launcher.EnvironmentVariables["KEY2"]); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual(2, launcher.EnvironmentVariables.Count); + Assert.AreEqual("val1", launcher.EnvironmentVariables["KEY1"]); + Assert.AreEqual("val2", launcher.EnvironmentVariables["KEY2"]); } - [Fact] + [TestMethod] public void EnvironmentOption_ValueWithEquals() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "CONN=Server=localhost;Port=5432" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("Server=localhost;Port=5432", launcher.EnvironmentVariables["CONN"]); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("Server=localhost;Port=5432", launcher.EnvironmentVariables["CONN"]); } - [Fact] + [TestMethod] public void EnvironmentOption_EmptyValue() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("", launcher.EnvironmentVariables["KEY"]); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("", launcher.EnvironmentVariables["KEY"]); } - [Fact] + [TestMethod] public void EnvironmentOption_NoEquals() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("", launcher.EnvironmentVariables["KEY"]); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("", launcher.EnvironmentVariables["KEY"]); } - [Fact] + [TestMethod] public void NoLaunchProfileOption() { // With no-launch-profile flag var argsNoProfile = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--no-launch-profile" }; - var launcherNoProfile = Assert.IsType(AspireLauncher.TryCreate(argsNoProfile)); - Assert.False(launcherNoProfile.LaunchProfileName.HasValue); + var launcherNoProfile = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); + Assert.IsFalse(launcherNoProfile.LaunchProfileName.HasValue); // Without no-launch-profile flag var argsDefault = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherDefault = Assert.IsType(AspireLauncher.TryCreate(argsDefault)); - Assert.True(launcherDefault.LaunchProfileName.HasValue); - Assert.Null(launcherDefault.LaunchProfileName.Value); + var launcherDefault = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsDefault)); + Assert.IsTrue(launcherDefault.LaunchProfileName.HasValue); + Assert.IsNull(launcherDefault.LaunchProfileName.Value); } - [Fact] + [TestMethod] public void LaunchProfileOption() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.True(launcher.LaunchProfileName.HasValue); - Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.IsTrue(launcher.LaunchProfileName.HasValue); + Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } - [Fact] + [TestMethod] public void LaunchProfileOption_ShortForm() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-lp", "MyProfile" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.True(launcher.LaunchProfileName.HasValue); - Assert.Equal("MyProfile", launcher.LaunchProfileName.Value); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.IsTrue(launcher.LaunchProfileName.HasValue); + Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } - [Fact] + [TestMethod] public void VerboseOption() { var argsVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--verbose" }; - var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); - Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); var argsNotVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); - Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void QuietOption() { var argsQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--quiet" }; - var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); - Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); var argsNotQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); - Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void ConflictingOptions() { // Cannot specify both --quiet and --verbose var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--quiet", "--verbose" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void AllOptionsSet() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "myapp.csproj", "-e", "K1=V1", "-e", "K2=V2", "--launch-profile", "Dev", "--verbose", "arg1", "arg2" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); - Assert.Equal("pipe1", launcher.ServerPipeName); - Assert.Equal("myapp.csproj", launcher.EntryPoint); - Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); - Assert.True(launcher.LaunchProfileName.HasValue); - Assert.Equal("Dev", launcher.LaunchProfileName.Value); + Assert.AreEqual("pipe1", launcher.ServerPipeName); + Assert.AreEqual("myapp.csproj", launcher.EntryPoint); + Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.IsTrue(launcher.LaunchProfileName.HasValue); + Assert.AreEqual("Dev", launcher.LaunchProfileName.Value); AssertEx.SequenceEqual(["arg1", "arg2"], launcher.ApplicationArguments); - Assert.Equal(2, launcher.EnvironmentVariables.Count); - Assert.Equal("V1", launcher.EnvironmentVariables["K1"]); - Assert.Equal("V2", launcher.EnvironmentVariables["K2"]); + Assert.AreEqual(2, launcher.EnvironmentVariables.Count); + Assert.AreEqual("V1", launcher.EnvironmentVariables["K1"]); + Assert.AreEqual("V2", launcher.EnvironmentVariables["K2"]); } - [Fact] + [TestMethod] public void EnvironmentOption_Duplicates() { var command = new AspireResourceCommandDefinition(); @@ -187,7 +188,7 @@ public void EnvironmentOption_Duplicates() result.Errors.Should().BeEmpty(); } - [Fact] + [TestMethod] public void EnvironmentOption_Duplicates_CasingDifference() { var command = new AspireResourceCommandDefinition(); @@ -212,7 +213,7 @@ public void EnvironmentOption_Duplicates_CasingDifference() result.Errors.Should().BeEmpty(); } - [Fact] + [TestMethod] public void EnvironmentOption_MultiplePerToken() { var command = new AspireResourceCommandDefinition(); @@ -230,7 +231,7 @@ public void EnvironmentOption_MultiplePerToken() result.Errors.Should().BeEmpty(); } - [Fact] + [TestMethod] public void EnvironmentOption_NoValue() { var command = new AspireResourceCommandDefinition(); @@ -243,7 +244,7 @@ public void EnvironmentOption_NoValue() result.Errors.Should().BeEmpty(); } - [Fact] + [TestMethod] public void EnvironmentOption_WhitespaceTrimming() { var command = new AspireResourceCommandDefinition(); @@ -256,11 +257,11 @@ public void EnvironmentOption_WhitespaceTrimming() result.Errors.Should().BeEmpty(); } - [Theory] - [InlineData("")] - [InlineData("=")] - [InlineData("= X")] - [InlineData(" \u2002 = X")] + [TestMethod] + [DataRow("")] + [DataRow("=")] + [DataRow("= X")] + [DataRow(" \u2002 = X")] public void EnvironmentOption_Errors(string token) { var command = new AspireResourceCommandDefinition(); @@ -271,4 +272,4 @@ public void EnvironmentOption_Errors(string token) $"Incorrectly formatted environment variables '{token}'" ], result.Errors.Select(e => e.Message)); } -} +} \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs index 39ba1fdff22b..91dcfe91bdf7 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs @@ -1,129 +1,130 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch.UnitTests; +[TestClass] public class AspireServerLauncherCliTests { - [Fact] + [TestMethod] public void RequiredServerOption() { // --server option is missing var args = new[] { "server", "--sdk", "sdk" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void RequiredSdkOption() { // --sdk option is missing var args = new[] { "server", "--server", "pipe1" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void MinimalRequiredOptions() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("pipe1", launcher.ServerPipeName); - Assert.Equal(LogLevel.Information, launcher.GlobalOptions.LogLevel); - Assert.Empty(launcher.ResourcePaths); - Assert.Null(launcher.StatusPipeName); - Assert.Null(launcher.ControlPipeName); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("pipe1", launcher.ServerPipeName); + Assert.AreEqual(LogLevel.Information, launcher.GlobalOptions.LogLevel); + Assert.IsEmpty(launcher.ResourcePaths); + Assert.IsNull(launcher.StatusPipeName); + Assert.IsNull(launcher.ControlPipeName); } - [Fact] + [TestMethod] public void ResourceOption_SingleValue() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj"], launcher.ResourcePaths); } - [Fact] + [TestMethod] public void ResourceOption_MultipleValues() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "file.cs" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj", "file.cs"], launcher.ResourcePaths); } - [Fact] + [TestMethod] public void ResourceOption_MultipleFlags() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "--resource", "proj2.csproj" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); } - [Fact] + [TestMethod] public void StatusPipeOption() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--status-pipe", "status1" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("status1", launcher.StatusPipeName); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("status1", launcher.StatusPipeName); } - [Fact] + [TestMethod] public void ControlPipeOption() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--control-pipe", "control1" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); - Assert.Equal("control1", launcher.ControlPipeName); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + Assert.AreEqual("control1", launcher.ControlPipeName); } - [Fact] + [TestMethod] public void VerboseOption() { // With verbose flag var argsVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--verbose" }; - var launcherVerbose = Assert.IsType(AspireLauncher.TryCreate(argsVerbose)); - Assert.Equal(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); + var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); // Without verbose flag var argsNotVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcherNotVerbose = Assert.IsType(AspireLauncher.TryCreate(argsNotVerbose)); - Assert.Equal(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); + var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void QuietOption() { // With quiet flag var argsQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--quiet" }; - var launcherQuiet = Assert.IsType(AspireLauncher.TryCreate(argsQuiet)); - Assert.Equal(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); + var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); // Without quiet flag var argsNotQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcherNotQuiet = Assert.IsType(AspireLauncher.TryCreate(argsNotQuiet)); - Assert.Equal(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); + var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } - [Fact] + [TestMethod] public void ConflictingOptions() { // Cannot specify both --quiet and --verbose var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--quiet", "--verbose" }; var launcher = AspireLauncher.TryCreate(args); - Assert.Null(launcher); + Assert.IsNull(launcher); } - [Fact] + [TestMethod] public void AllOptionsSet() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "--status-pipe", "status1", "--control-pipe", "control1", "--verbose" }; - var launcher = Assert.IsType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); - Assert.Equal("pipe1", launcher.ServerPipeName); - Assert.Equal(LogLevel.Debug, launcher.GlobalOptions.LogLevel); + Assert.AreEqual("pipe1", launcher.ServerPipeName); + Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); - Assert.Equal("status1", launcher.StatusPipeName); - Assert.Equal("control1", launcher.ControlPipeName); + Assert.AreEqual("status1", launcher.StatusPipeName); + Assert.AreEqual("control1", launcher.ControlPipeName); } -} +} \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs new file mode 100644 index 000000000000..67ae04e4e745 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch.UnitTests; + +/// +/// Small project-local helper that mirrors the shape of the xUnit-based AssertEx +/// in for the subset of +/// helpers used by the unit tests in this project. Keeping it local lets this project +/// stay independent from the xUnit-coupled shared test utilities. +/// +internal static class AssertEx +{ + public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string? message = null) + { + Assert.IsNotNull(actual); + + if (!expected.SequenceEqual(actual)) + { + var expectedString = string.Join(Environment.NewLine, expected.Select(FormatItem)); + var actualString = string.Join(Environment.NewLine, actual.Select(FormatItem)); + Assert.Fail( + (message is null ? string.Empty : message + Environment.NewLine) + + $"Expected:{Environment.NewLine}{expectedString}{Environment.NewLine}" + + $"Actual:{Environment.NewLine}{actualString}"); + } + + static string FormatItem(T item) => item?.ToString() ?? ""; + } +} diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj index e65ce1a88a56..3aa8c6452145 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj @@ -1,19 +1,23 @@ - + + + true + $(SdkTargetFramework) - Exe Microsoft.DotNet.Watch.Aspire.UnitTests MicrosoftAspNetCore - + + + + - - - + \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs b/test/dotnet-watch.Tests/Aspire/AspireLauncherIntegrationTests.cs similarity index 98% rename from test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs rename to test/dotnet-watch.Tests/Aspire/AspireLauncherIntegrationTests.cs index ceab4de2f838..2255ed3aa721 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs +++ b/test/dotnet-watch.Tests/Aspire/AspireLauncherIntegrationTests.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch.UnitTests; -public class AspireLauncherTests(ITestOutputHelper logger) : WatchSdkTest(logger) +public class AspireLauncherIntegrationTests(ITestOutputHelper logger) : WatchSdkTest(logger) { private WatchableApp CreateHostApp() => new( diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs b/test/dotnet-watch.Tests/Aspire/PipeUtilities.cs similarity index 100% rename from test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs rename to test/dotnet-watch.Tests/Aspire/PipeUtilities.cs diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 72312ae26fb1..6e31001e68f0 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -18,6 +18,8 @@ + + From 52eade30e7344c1b2a22a9d97883d5b7a8d829d8 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Thu, 11 Jun 2026 22:02:57 +0200 Subject: [PATCH 02/14] Bump MSTest.Sdk to 4.3.0-preview.26311.10 + apply skill review fixes - Bumps MSTest.Sdk to the latest internal preview to pick up the newest 4.3 assertion APIs (Assert.ContainsSingle, Assert.Contains for strings with the more natural (needle, haystack) signature, etc.). - Applies the assertion mapping flagged by the migrate-xunit-to-mstest skill in this repo (.github/skills/migrate-xunit-to-mstest, PR #54727): Assert.HasCount(1, x) -> Assert.ContainsSingle(x) (one occurrence in AspireResourceLauncherCliTests.cs) Verified: 58/58 tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- global.json | 2 +- .../AspireResourceLauncherCliTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 1dc269111cae..2b0f56d196c0 100644 --- a/global.json +++ b/global.json @@ -26,6 +26,6 @@ "Microsoft.Build.NoTargets": "3.7.134", "Microsoft.Build.Traversal": "4.1.82", "Microsoft.WixToolset.Sdk": "6.0.3-dotnet.4", - "MSTest.Sdk": "4.3.0-preview.26307.5" + "MSTest.Sdk": "4.3.0-preview.26311.10" } } diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs index ef5a3347ddde..5640079c6a7d 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs @@ -54,7 +54,7 @@ public void EnvironmentOption_SingleVariable() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=value" }; var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); - Assert.HasCount(1, launcher.EnvironmentVariables); + Assert.ContainsSingle(launcher.EnvironmentVariables); Assert.AreEqual("value", launcher.EnvironmentVariables["KEY"]); } From 75a20cf293aa17907166b6ce634372af0d7b56dd Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Thu, 11 Jun 2026 22:16:38 +0200 Subject: [PATCH 03/14] Address review: use Assert.IsExactInstanceOfType for xUnit IsType parity Per reviewer feedback: MSTest 4.1.0+ exposes Assert.IsExactInstanceOfType(value) which returns T and enforces exact-type semantics -- the proper equivalent of xUnit's Assert.IsType(x). Assert.IsInstanceOfType is the equivalent of xUnit's Assert.IsAssignableFrom (assignable, not exact), which would be a silent semantic regression for the IsType originals. All 39 occurrences across AspireHostLauncherCliTests.cs, AspireResourceLauncherCliTests.cs, AspireServerLauncherCliTests.cs, and AspireLauncherIntegrationTests.cs were originally Assert.IsType in xUnit (verified against main), so all 39 are flipped to Assert.IsExactInstanceOfType. Note: the migrate-xunit-to-mstest skill cheatsheet at .github/skills/migrate-xunit-to-mstest/references/mapping-cheatsheet.md recommends `Assert.IsInstanceOfType` plus an extra typeof-check for exact-type semantics; that guidance predates IsExactInstanceOfType being available. Follow-up upstream (dotnet/skills) suggested. Verified: 58/58 Aspire tests still pass; dotnet-watch.Tests builds clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AspireHostLauncherCliTests.cs | 24 +++++++------- .../AspireResourceLauncherCliTests.cs | 32 +++++++++---------- .../AspireServerLauncherCliTests.cs | 22 ++++++------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs index 23edc5e39889..40db79bbb9a4 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs @@ -30,7 +30,7 @@ public void RequiredEntryPointOption() public void ProjectAndSdkPaths() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myproject.csproj" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); Assert.IsTrue(launcher.EntryPoint.IsProjectFile); Assert.AreEqual("myproject.csproj", launcher.EntryPoint.PhysicalPath); @@ -42,7 +42,7 @@ public void ProjectAndSdkPaths() public void FilePath() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "file.cs" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); Assert.IsFalse(launcher.EntryPoint.IsProjectFile); Assert.AreEqual("file.cs", launcher.EntryPoint.EntryPointFilePath); @@ -54,7 +54,7 @@ public void FilePath() public void ApplicationArguments() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose", "a", "b" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); } @@ -64,12 +64,12 @@ public void VerboseOption() { // With verbose flag var argsVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose" }; - var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + var launcherVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); // Without verbose flag var argsNotVerbose = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + var launcherNotVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } @@ -78,12 +78,12 @@ public void QuietOption() { // With quiet flag var argsQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--quiet" }; - var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + var launcherQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); // Without quiet flag var argsNotQuiet = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + var launcherNotQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } @@ -92,12 +92,12 @@ public void NoLaunchProfileOption() { // With no-launch-profile flag var argsNoProfile = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--no-launch-profile" }; - var launcherNoProfile = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); + var launcherNoProfile = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); Assert.IsFalse(launcherNoProfile.LaunchProfileName.HasValue); // Without no-launch-profile flag var argsDefault = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj" }; - var launcherDefault = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsDefault)); + var launcherDefault = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsDefault)); Assert.IsTrue(launcherDefault.LaunchProfileName.HasValue); Assert.IsNull(launcherDefault.LaunchProfileName.Value); } @@ -106,7 +106,7 @@ public void NoLaunchProfileOption() public void LaunchProfileOption() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.IsTrue(launcher.LaunchProfileName.HasValue); Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } @@ -125,7 +125,7 @@ public void EntryPoint_MultipleValues() { // EntryPoint option should only accept one value; extra values become application arguments var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj1", "proj2" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath); AssertEx.SequenceEqual(["proj2"], launcher.ApplicationArguments); } @@ -134,7 +134,7 @@ public void EntryPoint_MultipleValues() public void AllOptionsSet() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "myapp.csproj", "--verbose", "--no-launch-profile", "arg1", "arg2", "arg3" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.IsTrue(launcher.EntryPoint.IsProjectFile); Assert.AreEqual("myapp.csproj", launcher.EntryPoint.PhysicalPath); diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs index 5640079c6a7d..06efb2e94417 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs @@ -31,7 +31,7 @@ public void RequiredEntryPointOption() public void MinimalRequiredOptions() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj.csproj" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("pipe1", launcher.ServerPipeName); Assert.AreEqual("proj.csproj", launcher.EntryPoint); Assert.IsEmpty(launcher.ApplicationArguments); @@ -45,7 +45,7 @@ public void MinimalRequiredOptions() public void ApplicationArguments() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "a", "b" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); } @@ -53,7 +53,7 @@ public void ApplicationArguments() public void EnvironmentOption_SingleVariable() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=value" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.ContainsSingle(launcher.EnvironmentVariables); Assert.AreEqual("value", launcher.EnvironmentVariables["KEY"]); } @@ -62,7 +62,7 @@ public void EnvironmentOption_SingleVariable() public void EnvironmentOption_MultipleVariables() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY1=val1", "-e", "KEY2=val2" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual(2, launcher.EnvironmentVariables.Count); Assert.AreEqual("val1", launcher.EnvironmentVariables["KEY1"]); Assert.AreEqual("val2", launcher.EnvironmentVariables["KEY2"]); @@ -72,7 +72,7 @@ public void EnvironmentOption_MultipleVariables() public void EnvironmentOption_ValueWithEquals() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "CONN=Server=localhost;Port=5432" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("Server=localhost;Port=5432", launcher.EnvironmentVariables["CONN"]); } @@ -80,7 +80,7 @@ public void EnvironmentOption_ValueWithEquals() public void EnvironmentOption_EmptyValue() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY=" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("", launcher.EnvironmentVariables["KEY"]); } @@ -88,7 +88,7 @@ public void EnvironmentOption_EmptyValue() public void EnvironmentOption_NoEquals() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-e", "KEY" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("", launcher.EnvironmentVariables["KEY"]); } @@ -97,12 +97,12 @@ public void NoLaunchProfileOption() { // With no-launch-profile flag var argsNoProfile = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--no-launch-profile" }; - var launcherNoProfile = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); + var launcherNoProfile = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNoProfile)); Assert.IsFalse(launcherNoProfile.LaunchProfileName.HasValue); // Without no-launch-profile flag var argsDefault = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherDefault = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsDefault)); + var launcherDefault = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsDefault)); Assert.IsTrue(launcherDefault.LaunchProfileName.HasValue); Assert.IsNull(launcherDefault.LaunchProfileName.Value); } @@ -111,7 +111,7 @@ public void NoLaunchProfileOption() public void LaunchProfileOption() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--launch-profile", "MyProfile" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.IsTrue(launcher.LaunchProfileName.HasValue); Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } @@ -120,7 +120,7 @@ public void LaunchProfileOption() public void LaunchProfileOption_ShortForm() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "-lp", "MyProfile" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.IsTrue(launcher.LaunchProfileName.HasValue); Assert.AreEqual("MyProfile", launcher.LaunchProfileName.Value); } @@ -129,11 +129,11 @@ public void LaunchProfileOption_ShortForm() public void VerboseOption() { var argsVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--verbose" }; - var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + var launcherVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); var argsNotVerbose = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + var launcherNotVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } @@ -141,11 +141,11 @@ public void VerboseOption() public void QuietOption() { var argsQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "--quiet" }; - var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + var launcherQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); var argsNotQuiet = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj" }; - var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + var launcherNotQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } @@ -162,7 +162,7 @@ public void ConflictingOptions() public void AllOptionsSet() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "myapp.csproj", "-e", "K1=V1", "-e", "K2=V2", "--launch-profile", "Dev", "--verbose", "arg1", "arg2" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("pipe1", launcher.ServerPipeName); Assert.AreEqual("myapp.csproj", launcher.EntryPoint); diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs index 91dcfe91bdf7..b1bf32c9b82f 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs @@ -30,7 +30,7 @@ public void RequiredSdkOption() public void MinimalRequiredOptions() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("pipe1", launcher.ServerPipeName); Assert.AreEqual(LogLevel.Information, launcher.GlobalOptions.LogLevel); Assert.IsEmpty(launcher.ResourcePaths); @@ -42,7 +42,7 @@ public void MinimalRequiredOptions() public void ResourceOption_SingleValue() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj"], launcher.ResourcePaths); } @@ -50,7 +50,7 @@ public void ResourceOption_SingleValue() public void ResourceOption_MultipleValues() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "file.cs" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj", "file.cs"], launcher.ResourcePaths); } @@ -58,7 +58,7 @@ public void ResourceOption_MultipleValues() public void ResourceOption_MultipleFlags() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "--resource", "proj2.csproj" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); } @@ -66,7 +66,7 @@ public void ResourceOption_MultipleFlags() public void StatusPipeOption() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--status-pipe", "status1" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("status1", launcher.StatusPipeName); } @@ -74,7 +74,7 @@ public void StatusPipeOption() public void ControlPipeOption() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--control-pipe", "control1" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("control1", launcher.ControlPipeName); } @@ -83,12 +83,12 @@ public void VerboseOption() { // With verbose flag var argsVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--verbose" }; - var launcherVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); + var launcherVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsVerbose)); Assert.AreEqual(LogLevel.Debug, launcherVerbose.GlobalOptions.LogLevel); // Without verbose flag var argsNotVerbose = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcherNotVerbose = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); + var launcherNotVerbose = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotVerbose)); Assert.AreEqual(LogLevel.Information, launcherNotVerbose.GlobalOptions.LogLevel); } @@ -97,12 +97,12 @@ public void QuietOption() { // With quiet flag var argsQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--quiet" }; - var launcherQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); + var launcherQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsQuiet)); Assert.AreEqual(LogLevel.Warning, launcherQuiet.GlobalOptions.LogLevel); // Without quiet flag var argsNotQuiet = new[] { "server", "--server", "pipe1", "--sdk", "sdk" }; - var launcherNotQuiet = Assert.IsInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); + var launcherNotQuiet = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(argsNotQuiet)); Assert.AreEqual(LogLevel.Information, launcherNotQuiet.GlobalOptions.LogLevel); } @@ -119,7 +119,7 @@ public void ConflictingOptions() public void AllOptionsSet() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "--status-pipe", "status1", "--control-pipe", "control1", "--verbose" }; - var launcher = Assert.IsInstanceOfType(AspireLauncher.TryCreate(args)); + var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("pipe1", launcher.ServerPipeName); Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); From 27da8c4aa0d5b7ff5aa66d59d52f5631d6e7552d Mon Sep 17 00:00:00 2001 From: Evangelink Date: Thu, 11 Jun 2026 22:32:03 +0200 Subject: [PATCH 04/14] Remove redundant Using of Microsoft.VisualStudio.TestTools.UnitTesting MSTest.Sdk already adds this as an implicit global using. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj index 3aa8c6452145..a7be15d830b3 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj @@ -13,7 +13,6 @@ - From 7a551ebccb67adbfb3ad1b17db1e8fce28e6961d Mon Sep 17 00:00:00 2001 From: Evangelink Date: Thu, 11 Jun 2026 23:03:36 +0200 Subject: [PATCH 05/14] Replace AssertEx.SequenceEqual with Assert.AreSequenceEqual MSTest 4.3+ provides Assert.AreSequenceEqual for element-wise IEnumerable compare with a nice diff message, so the project-local AssertEx helper is no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AspireHostLauncherCliTests.cs | 6 ++-- .../AspireHostLauncherTests.cs | 16 +++++----- .../AspireResourceLauncherCliTests.cs | 6 ++-- .../AspireServerLauncherCliTests.cs | 8 ++--- .../AssertEx.cs | 30 ------------------- 5 files changed, 18 insertions(+), 48 deletions(-) delete mode 100644 test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs index 40db79bbb9a4..c5b3e40cf567 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherCliTests.cs @@ -55,7 +55,7 @@ public void ApplicationArguments() { var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj", "--verbose", "a", "b" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); - AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); + Assert.AreSequenceEqual(["a", "b"], launcher.ApplicationArguments); Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); } @@ -127,7 +127,7 @@ public void EntryPoint_MultipleValues() var args = new[] { "host", "--sdk", "sdk", "--entrypoint", "proj1", "proj2" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); Assert.AreEqual("proj1", launcher.EntryPoint.ProjectOrEntryPointFilePath); - AssertEx.SequenceEqual(["proj2"], launcher.ApplicationArguments); + Assert.AreSequenceEqual(["proj2"], launcher.ApplicationArguments); } [TestMethod] @@ -141,6 +141,6 @@ public void AllOptionsSet() Assert.AreEqual("sdk", launcher.EnvironmentOptions.SdkDirectory); Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); Assert.IsFalse(launcher.LaunchProfileName.HasValue); - AssertEx.SequenceEqual(["arg1", "arg2", "arg3"], launcher.ApplicationArguments); + Assert.AreSequenceEqual(["arg1", "arg2", "arg3"], launcher.ApplicationArguments); } } \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs index 33cf07e5aa9e..23ca92043e70 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs @@ -49,7 +49,7 @@ public void GetProjectOptions_ProjectFile_UsesProjectFlag() AssertCommonProperties(options, launcher); Assert.IsFalse(options.LaunchProfileName.HasValue); - AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); } [TestMethod] @@ -61,7 +61,7 @@ public void GetProjectOptions_EntryPointFile_UsesFileFlag() AssertCommonProperties(options, launcher); Assert.IsFalse(options.LaunchProfileName.HasValue); - AssertEx.SequenceEqual(["--file", "Program.cs", "--no-launch-profile"], options.CommandArguments); + Assert.AreSequenceEqual(["--file", "Program.cs", "--no-launch-profile"], options.CommandArguments); } [TestMethod] @@ -74,7 +74,7 @@ public void GetProjectOptions_WithLaunchProfile_AddsLaunchProfileArguments() AssertCommonProperties(options, launcher); Assert.IsTrue(options.LaunchProfileName.HasValue); Assert.AreEqual("MyProfile", options.LaunchProfileName.Value); - AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "MyProfile"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj", "--launch-profile", "MyProfile"], options.CommandArguments); } [TestMethod] @@ -86,7 +86,7 @@ public void GetProjectOptions_NoLaunchProfile_AddsNoLaunchProfileFlag() AssertCommonProperties(options, launcher); Assert.IsFalse(options.LaunchProfileName.HasValue); - AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj", "--no-launch-profile"], options.CommandArguments); } [TestMethod] @@ -100,7 +100,7 @@ public void GetProjectOptions_NullLaunchProfile_UsesDefault() AssertCommonProperties(options, launcher); Assert.IsTrue(options.LaunchProfileName.HasValue); Assert.IsNull(options.LaunchProfileName.Value); - AssertEx.SequenceEqual(["--project", "myapp.csproj"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj"], options.CommandArguments); } [TestMethod] @@ -113,7 +113,7 @@ public void GetProjectOptions_WithApplicationArguments_AppendsArguments() AssertCommonProperties(options, launcher); Assert.IsTrue(options.LaunchProfileName.HasValue); Assert.AreEqual("Profile", options.LaunchProfileName.Value); - AssertEx.SequenceEqual(["--project", "myapp.csproj", "--launch-profile", "Profile", "arg1", "arg2"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj", "--launch-profile", "Profile", "arg1", "arg2"], options.CommandArguments); } [TestMethod] @@ -137,7 +137,7 @@ public void GetProjectOptions_EntryPointFile_WithLaunchProfileAndArguments() AssertCommonProperties(options, launcher); Assert.IsTrue(options.LaunchProfileName.HasValue); Assert.AreEqual("Dev", options.LaunchProfileName.Value); - AssertEx.SequenceEqual(["--file", "Program.cs", "--launch-profile", "Dev", "--port", "8080"], options.CommandArguments); + Assert.AreSequenceEqual(["--file", "Program.cs", "--launch-profile", "Dev", "--port", "8080"], options.CommandArguments); } [TestMethod] @@ -149,6 +149,6 @@ public void GetProjectOptions_NoLaunchProfile_WithApplicationArguments() AssertCommonProperties(options, launcher); Assert.IsFalse(options.LaunchProfileName.HasValue); - AssertEx.SequenceEqual(["--project", "myapp.csproj", "--no-launch-profile", "--urls", "http://localhost:5000"], options.CommandArguments); + Assert.AreSequenceEqual(["--project", "myapp.csproj", "--no-launch-profile", "--urls", "http://localhost:5000"], options.CommandArguments); } } \ No newline at end of file diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs index 06efb2e94417..a4cb31a69526 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireResourceLauncherCliTests.cs @@ -46,7 +46,7 @@ public void ApplicationArguments() { var args = new[] { "resource", "--server", "pipe1", "--entrypoint", "proj", "a", "b" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); - AssertEx.SequenceEqual(["a", "b"], launcher.ApplicationArguments); + Assert.AreSequenceEqual(["a", "b"], launcher.ApplicationArguments); } [TestMethod] @@ -169,7 +169,7 @@ public void AllOptionsSet() Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); Assert.IsTrue(launcher.LaunchProfileName.HasValue); Assert.AreEqual("Dev", launcher.LaunchProfileName.Value); - AssertEx.SequenceEqual(["arg1", "arg2"], launcher.ApplicationArguments); + Assert.AreSequenceEqual(["arg1", "arg2"], launcher.ApplicationArguments); Assert.AreEqual(2, launcher.EnvironmentVariables.Count); Assert.AreEqual("V1", launcher.EnvironmentVariables["K1"]); Assert.AreEqual("V2", launcher.EnvironmentVariables["K2"]); @@ -267,7 +267,7 @@ public void EnvironmentOption_Errors(string token) var command = new AspireResourceCommandDefinition(); var result = command.Parse(["--server", "S", "--entrypoint", "E", "-e", token]); - AssertEx.SequenceEqual( + Assert.AreSequenceEqual( [ $"Incorrectly formatted environment variables '{token}'" ], result.Errors.Select(e => e.Message)); diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs index b1bf32c9b82f..127b20e9ca0f 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireServerLauncherCliTests.cs @@ -43,7 +43,7 @@ public void ResourceOption_SingleValue() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); - AssertEx.SequenceEqual(["proj1.csproj"], launcher.ResourcePaths); + Assert.AreSequenceEqual(["proj1.csproj"], launcher.ResourcePaths); } [TestMethod] @@ -51,7 +51,7 @@ public void ResourceOption_MultipleValues() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "proj2.csproj", "file.cs" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); - AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj", "file.cs"], launcher.ResourcePaths); + Assert.AreSequenceEqual(["proj1.csproj", "proj2.csproj", "file.cs"], launcher.ResourcePaths); } [TestMethod] @@ -59,7 +59,7 @@ public void ResourceOption_MultipleFlags() { var args = new[] { "server", "--server", "pipe1", "--sdk", "sdk", "--resource", "proj1.csproj", "--resource", "proj2.csproj" }; var launcher = Assert.IsExactInstanceOfType(AspireLauncher.TryCreate(args)); - AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); + Assert.AreSequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); } [TestMethod] @@ -123,7 +123,7 @@ public void AllOptionsSet() Assert.AreEqual("pipe1", launcher.ServerPipeName); Assert.AreEqual(LogLevel.Debug, launcher.GlobalOptions.LogLevel); - AssertEx.SequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); + Assert.AreSequenceEqual(["proj1.csproj", "proj2.csproj"], launcher.ResourcePaths); Assert.AreEqual("status1", launcher.StatusPipeName); Assert.AreEqual("control1", launcher.ControlPipeName); } diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs deleted file mode 100644 index 67ae04e4e745..000000000000 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AssertEx.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Watch.UnitTests; - -/// -/// Small project-local helper that mirrors the shape of the xUnit-based AssertEx -/// in for the subset of -/// helpers used by the unit tests in this project. Keeping it local lets this project -/// stay independent from the xUnit-coupled shared test utilities. -/// -internal static class AssertEx -{ - public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string? message = null) - { - Assert.IsNotNull(actual); - - if (!expected.SequenceEqual(actual)) - { - var expectedString = string.Join(Environment.NewLine, expected.Select(FormatItem)); - var actualString = string.Join(Environment.NewLine, actual.Select(FormatItem)); - Assert.Fail( - (message is null ? string.Empty : message + Environment.NewLine) + - $"Expected:{Environment.NewLine}{expectedString}{Environment.NewLine}" + - $"Actual:{Environment.NewLine}{actualString}"); - } - - static string FormatItem(T item) => item?.ToString() ?? ""; - } -} From cb5098995948cabacda6d93f175533cddedfde04 Mon Sep 17 00:00:00 2001 From: Evangelink <32149626+Evangelink@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:19:48 +0200 Subject: [PATCH 06/14] Move FluentAssertions Using + AwesomeAssertions PackageReference to test/Directory.Build.targets Per @Evangelink: keep per-csproj boilerplate minimal. FluentAssertions is now a global using for any test project (gated on IsTestProject OR UsingMSTestSdk), and AwesomeAssertions is added as a PackageReference for MSTest.Sdk projects. xUnit projects continue to pick it up transitively via Microsoft.NET.TestFramework. The Microsoft.NET.TestFramework.* and Xunit usings remain gated on the xUnit branch (UsingMSTestSdk != true) because MSTest projects in this repo do not reference Microsoft.NET.TestFramework; making those usings global would fail with CS0246. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj index a7be15d830b3..2ae0f2fe0244 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj @@ -10,11 +10,6 @@ MicrosoftAspNetCore - - - - - From ead0658e82a0946b58fa2decf327f229603696ee Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Fri, 12 Jun 2026 16:07:51 +0200 Subject: [PATCH 07/14] Restore Watch.Aspire ProjectReference in dotnet-watch.Tests.csproj The recent `Helix dispatcher: gate --report-trx on TrxReport extension being loaded` commit accidentally removed the ProjectReference to Microsoft.DotNet.HotReload.Watch.Aspire from dotnet-watch.Tests.csproj (introduced in the `Migrate Microsoft.DotNet.HotReload.Watch.Aspire.Tests to MSTest.Sdk on MTP` commit to allow the moved AspireLauncherIntegrationTests and PipeUtilities to compile). Without that reference, the build fails with: error CS0246: The type or namespace name 'WatchStatusEvent' could not be found (are you missing a using directive or an assembly reference?) [test/dotnet-watch.Tests/Aspire/PipeUtilities.cs] Re-add the ProjectReference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/dotnet-watch.Tests/dotnet-watch.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 6e31001e68f0..3b45b8eb3031 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -1,4 +1,4 @@ - + Exe $(SdkTargetFramework) From fe718beb6b20d0e227ad7410054bd7a949f05b4f Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Fri, 12 Jun 2026 18:29:09 +0200 Subject: [PATCH 08/14] Add shared Microsoft.DotNet.Test.MSTest.Utilities project Introduces an MSTest-flavored counterpart to the xUnit-based Microsoft.DotNet.HotReload.Test.Utilities so that test helpers that need MSTest's TestContext (e.g. TestLogger, TestLoggerFactory) can be shared across MSTest.Sdk test projects instead of being copy-pasted per project. This commit also migrates the inline TestLogger from Microsoft.DotNet.HotReload.Client.Tests to consume the shared project, which serves as the first reference consumer. Subsequent migration PRs (DeltaApplier.Tests, Containers.UnitTests) will adopt the same project reference instead of adding their own TestLogger/TestLoggerFactory copies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk.slnx | 1 + src/Dotnet.Watch/dotnet-watch.slnf | 1 + ...osoft.DotNet.HotReload.Client.Tests.csproj | 4 + .../StaticWebAssetsManifestTests.cs | 2 +- .../InMemoryLoggerProvider.cs | 39 ++++++++ ...rosoft.DotNet.Test.MSTest.Utilities.csproj | 42 ++++++++ .../TestLogger.cs | 14 ++- .../TestLoggerFactory.cs | 97 +++++++++++++++++++ 8 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 test/Microsoft.DotNet.Test.MSTest.Utilities/InMemoryLoggerProvider.cs create mode 100644 test/Microsoft.DotNet.Test.MSTest.Utilities/Microsoft.DotNet.Test.MSTest.Utilities.csproj rename test/{Microsoft.DotNet.HotReload.Client.Tests/Utilities => Microsoft.DotNet.Test.MSTest.Utilities}/TestLogger.cs (70%) create mode 100644 test/Microsoft.DotNet.Test.MSTest.Utilities/TestLoggerFactory.cs diff --git a/sdk.slnx b/sdk.slnx index df9722223799..07d879bec7b9 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -367,6 +367,7 @@ + diff --git a/src/Dotnet.Watch/dotnet-watch.slnf b/src/Dotnet.Watch/dotnet-watch.slnf index 48e156d1458e..b5600195a12e 100644 --- a/src/Dotnet.Watch/dotnet-watch.slnf +++ b/src/Dotnet.Watch/dotnet-watch.slnf @@ -33,6 +33,7 @@ "test\\dotnet-watch-test-browser\\dotnet-watch-test-browser.csproj", "test\\Microsoft.DotNet.HotReload.Test.Utilities\\Microsoft.DotNet.HotReload.Test.Utilities.csproj", "test\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj", + "test\\Microsoft.DotNet.Test.MSTest.Utilities\\Microsoft.DotNet.Test.MSTest.Utilities.csproj", "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj", "test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj" ] diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj index 7500dad99340..ef519bdfcde4 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs index 87d3eadc3176..e82b59ba8884 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Watch.UnitTests; +using Microsoft.DotNet.Test.MSTest.Utilities; using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.HotReload.UnitTests; diff --git a/test/Microsoft.DotNet.Test.MSTest.Utilities/InMemoryLoggerProvider.cs b/test/Microsoft.DotNet.Test.MSTest.Utilities/InMemoryLoggerProvider.cs new file mode 100644 index 000000000000..3715dac3febe --- /dev/null +++ b/test/Microsoft.DotNet.Test.MSTest.Utilities/InMemoryLoggerProvider.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Test.MSTest.Utilities; + +/// +/// An that appends every log entry to a caller-supplied list. +/// Useful for tests that need to assert on the exact sequence of log entries produced by a +/// component under test (without dragging in MSTest's TestContext sink). +/// +public sealed class InMemoryLoggerProvider(List<(LogLevel, string)> messagesCollection) : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new InMemoryLogger(messagesCollection); + + public void Dispose() + { + } + + private sealed class InMemoryLogger(List<(LogLevel, string)> messagesCollection) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => messagesCollection.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/test/Microsoft.DotNet.Test.MSTest.Utilities/Microsoft.DotNet.Test.MSTest.Utilities.csproj b/test/Microsoft.DotNet.Test.MSTest.Utilities/Microsoft.DotNet.Test.MSTest.Utilities.csproj new file mode 100644 index 000000000000..73adf7653621 --- /dev/null +++ b/test/Microsoft.DotNet.Test.MSTest.Utilities/Microsoft.DotNet.Test.MSTest.Utilities.csproj @@ -0,0 +1,42 @@ + + + + + + $(SdkTargetFramework);$(NetFrameworkToolCurrent) + Library + Microsoft.DotNet.Test.MSTest.Utilities + MicrosoftAspNetCore + false + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Utilities/TestLogger.cs b/test/Microsoft.DotNet.Test.MSTest.Utilities/TestLogger.cs similarity index 70% rename from test/Microsoft.DotNet.HotReload.Client.Tests/Utilities/TestLogger.cs rename to test/Microsoft.DotNet.Test.MSTest.Utilities/TestLogger.cs index 6bb15080c57d..87c8c1248f62 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/Utilities/TestLogger.cs +++ b/test/Microsoft.DotNet.Test.MSTest.Utilities/TestLogger.cs @@ -1,12 +1,18 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Microsoft.DotNet.Watch.UnitTests; +namespace Microsoft.DotNet.Test.MSTest.Utilities; -internal class TestLogger(TestContext? output = null) : ILogger +/// +/// An that captures messages in memory and optionally echoes them to an +/// MSTest . Designed to be shared across MSTest.Sdk test projects so +/// the same pattern doesn't have to be duplicated per project. +/// +public class TestLogger(TestContext? testContext = null) : ILogger { public readonly object Guard = new(); private readonly List _messages = []; @@ -31,7 +37,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except HasWarning |= logLevel is LogLevel.Warning; _messages.Add(message); - output?.WriteLine(message); + testContext?.WriteLine(message); } } diff --git a/test/Microsoft.DotNet.Test.MSTest.Utilities/TestLoggerFactory.cs b/test/Microsoft.DotNet.Test.MSTest.Utilities/TestLoggerFactory.cs new file mode 100644 index 000000000000..15485a93f137 --- /dev/null +++ b/test/Microsoft.DotNet.Test.MSTest.Utilities/TestLoggerFactory.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.DotNet.Test.MSTest.Utilities; + +/// +/// An that writes log messages to an MSTest +/// (when provided) and a simple console sink. Useful for tests that +/// need a real (e.g. for components that take one in their ctor). +/// +public sealed class TestLoggerFactory : ILoggerFactory +{ + private readonly List _loggerProviders = new(); + private readonly List _factories = new(); + + public TestLoggerFactory(TestContext? testContext = null) + { + if (testContext is not null) + { + _loggerProviders.Add(new TestContextLoggerProvider(testContext)); + } + } + + public void Dispose() + { + while (_factories.Count > 0) + { + ILoggerFactory factory = _factories[0]; + _factories.RemoveAt(0); + factory.Dispose(); + } + } + + public ILogger CreateLogger(string categoryName) + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + + foreach (ILoggerProvider loggerProvider in _loggerProviders) + { + builder.AddProvider(loggerProvider); + } + + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss.fff] "; + options.IncludeScopes = true; + }); + }); + + _factories.Add(loggerFactory); + return loggerFactory.CreateLogger(categoryName); + } + + public ILogger CreateLogger() => CreateLogger("Test Host"); + + public void AddProvider(ILoggerProvider provider) => _loggerProviders.Add(provider); + + private sealed class TestContextLoggerProvider(TestContext testContext) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) => new TestContextLogger(testContext, categoryName); + + public void Dispose() + { + } + } + + private sealed class TestContextLogger(TestContext testContext, string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + testContext.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); + if (exception is not null) + { + testContext.WriteLine(exception.ToString()); + } + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} From d8d00b47b7eaad6072515470394c2cf1c7d8bb88 Mon Sep 17 00:00:00 2001 From: Evangelink Date: Fri, 12 Jun 2026 00:12:19 +0200 Subject: [PATCH 09/14] Migrate Microsoft.NET.Build.Containers.UnitTests to MSTest.Sdk - Switch SDK to MSTest.Sdk; set UseMSTestSdk=true to opt out of test/Directory.Build.targets xUnit defaults. - Replace [Fact]/[Theory] + [InlineData]/[MemberData] with MSTest attributes. - Replace ITestOutputHelper-based logging with TestContext-backed test logging helpers. - Convert xUnit assertions to MSTest assertions and preserve dynamic Docker skips with MSTest conditions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AuthHandshakeMessageHandlerTests.cs | 225 +++++++++--------- .../ContainerHelpersTests.cs | 181 +++++++------- .../ContentStoreTests.cs | 17 +- .../CreateNewImageTests.cs | 71 +++--- .../DescriptorTests.cs | 15 +- .../DigestUtilsTests.cs | 57 ++--- .../DockerAvailableUtils.cs | 56 ++--- .../DockerDaemonTests.cs | 29 ++- .../FallbackToHttpMessageHandlerTests.cs | 25 +- .../ImageBuilderTests.cs | 136 ++++++----- .../ImageConfigTests.cs | 11 +- .../ImageIndexGeneratorTests.cs | 45 ++-- .../InMemoryLoggerProvider.cs | 34 +++ ...soft.NET.Build.Containers.UnitTests.csproj | 15 +- .../RegistryTests.cs | 130 +++++----- .../Resources/ResourceTests.cs | 18 +- .../TestLoggerFactory.cs | 91 +++++++ 17 files changed, 644 insertions(+), 512 deletions(-) create mode 100644 test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs create mode 100644 test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/AuthHandshakeMessageHandlerTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/AuthHandshakeMessageHandlerTests.cs index 21304ca608a0..41d9c1f72e34 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/AuthHandshakeMessageHandlerTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/AuthHandshakeMessageHandlerTests.cs @@ -9,18 +9,21 @@ namespace Microsoft.NET.Build.Containers.UnitTests { + [TestClass] public class AuthHandshakeMessageHandlerTests { + public TestContext TestContext { get; set; } = default!; + private const string TestRegistryName = "registry.test"; private const string RequestUrl = $"https://{TestRegistryName}/v2"; private const string BearerRealmUrl = $"https://bearer.test/token"; - [Theory] - [InlineData("SDK_CONTAINER_REGISTRY_UNAME", "SDK_CONTAINER_REGISTRY_PWORD", (int)RegistryMode.Push)] - [InlineData("DOTNET_CONTAINER_PUSH_REGISTRY_UNAME", "DOTNET_CONTAINER_PUSH_REGISTRY_PWORD", (int)RegistryMode.Push)] - [InlineData("DOTNET_CONTAINER_PULL_REGISTRY_UNAME", "DOTNET_CONTAINER_PULL_REGISTRY_PWORD", (int)RegistryMode.Pull)] - [InlineData("DOTNET_CONTAINER_PULL_REGISTRY_UNAME", "DOTNET_CONTAINER_PULL_REGISTRY_PWORD", (int)RegistryMode.PullFromOutput)] - [InlineData("SDK_CONTAINER_REGISTRY_UNAME", "SDK_CONTAINER_REGISTRY_PWORD", (int)RegistryMode.PullFromOutput)] + [TestMethod] + [DataRow("SDK_CONTAINER_REGISTRY_UNAME", "SDK_CONTAINER_REGISTRY_PWORD", (int)RegistryMode.Push)] + [DataRow("DOTNET_CONTAINER_PUSH_REGISTRY_UNAME", "DOTNET_CONTAINER_PUSH_REGISTRY_PWORD", (int)RegistryMode.Push)] + [DataRow("DOTNET_CONTAINER_PULL_REGISTRY_UNAME", "DOTNET_CONTAINER_PULL_REGISTRY_PWORD", (int)RegistryMode.Pull)] + [DataRow("DOTNET_CONTAINER_PULL_REGISTRY_UNAME", "DOTNET_CONTAINER_PULL_REGISTRY_PWORD", (int)RegistryMode.PullFromOutput)] + [DataRow("SDK_CONTAINER_REGISTRY_UNAME", "SDK_CONTAINER_REGISTRY_PWORD", (int)RegistryMode.PullFromOutput)] public void GetDockerCredentialsFromEnvironment_ReturnsCorrectValues(string unameVarName, string pwordVarName, int mode) { string? originalUnameValue = Environment.GetEnvironmentVariable(unameVarName); @@ -31,8 +34,8 @@ public void GetDockerCredentialsFromEnvironment_ReturnsCorrectValues(string unam if (AuthHandshakeMessageHandler.GetDockerCredentialsFromEnvironment((RegistryMode)mode) is (string credU, string credP)) { - Assert.Equal("uname", credU); - Assert.Equal("pword", credP); + Assert.AreEqual("uname", credU); + Assert.AreEqual("pword", credP); } else { @@ -45,8 +48,8 @@ public void GetDockerCredentialsFromEnvironment_ReturnsCorrectValues(string unam Environment.SetEnvironmentVariable(pwordVarName, originalPwordValue); } - [Theory] - [MemberData(nameof(GetAuthenticateTestData))] + [TestMethod] + [DynamicData(nameof(GetAuthenticateTestData))] public async Task Authenticate(string authConf, Func server) { string authFile = Path.GetTempFileName(); @@ -58,8 +61,8 @@ public async Task Authenticate(string authConf, Func ServerWithBearerAuth(string if (request.RequestUri?.ToString() == BearerRealmUrl) { // Verify the method is the expected one. - Assert.Equal(method, request.Method); + Assert.AreEqual(method, request.Method); // Verify the query parameter are the expected ones. AssertParametersAreEqual(queryParameters, request.RequestUri.Query); @@ -229,13 +232,13 @@ static Func ServerWithBearerAuth(string AuthenticationHeaderValue? header = request.Headers.Authorization; if (authHeader is not null) { - Assert.NotNull(header); - Assert.Equal(header.Scheme, authHeader.Scheme); - Assert.Equal(header.Parameter, authHeader.Parameter); + Assert.IsNotNull(header); + Assert.AreEqual(header.Scheme, authHeader.Scheme); + Assert.AreEqual(header.Parameter, authHeader.Parameter); } else { - Assert.Null(header); + Assert.IsNull(header); } // Verify the content. @@ -264,9 +267,9 @@ static void AssertParametersAreEqual(Dictionary expected, string NameValueCollection parsedParameters = HttpUtility.ParseQueryString(actual); foreach (var parameter in expected) { - Assert.Equal(parameter.Value, parsedParameters.Get(parameter.Key)); + Assert.AreEqual(parameter.Value, parsedParameters.Get(parameter.Key)); } - Assert.Equal(expected.Count, parsedParameters.AllKeys.Length); + Assert.AreEqual(expected.Count, parsedParameters.AllKeys.Length); } } @@ -310,15 +313,15 @@ protected override Task SendAsync(HttpRequestMessage reques /// non-default ports and public-routable IP literals), and http realms are accepted only /// when the registry is configured as insecure (explicit operator opt-in to downgrade). /// - [Theory] - [InlineData("https://auth.example.com/token", false)] - [InlineData("https://auth.example.com:8443/token", false)] - [InlineData("https://203.0.113.10/token", false)] // TEST-NET-3 doc IP, outside every blocked range, must be allowed - [InlineData("http://auth.example.com/token", true)] // downgrade permitted only when insecure + [TestMethod] + [DataRow("https://auth.example.com/token", false)] + [DataRow("https://auth.example.com:8443/token", false)] + [DataRow("https://203.0.113.10/token", false)] // TEST-NET-3 doc IP, outside every blocked range, must be allowed + [DataRow("http://auth.example.com/token", true)] // downgrade permitted only when insecure public void ValidateRealmUri_AcceptsAllowedSchemes(string realm, bool isInsecureRegistry) { Uri uri = AuthHandshakeMessageHandler.ValidateRealmUri(realm, "registry.example.com", isInsecureRegistry); - Assert.Equal(realm, uri.AbsoluteUri); + Assert.AreEqual(realm, uri.AbsoluteUri); } /// @@ -326,14 +329,14 @@ public void ValidateRealmUri_AcceptsAllowedSchemes(string realm, bool isInsecure /// registry, and any non-http(s) scheme regardless of the insecure flag. Defends /// against credential downgrade and exfiltration to non-HTTP transports. /// - [Theory] - [InlineData("http://auth.example.com/token", false)] // http on secure registry - [InlineData("ftp://auth.example.com/token", false)] // unsupported scheme on secure registry - [InlineData("ftp://auth.example.com/token", true)] // unsupported scheme stays rejected even when insecure - [InlineData("file:///etc/passwd", false)] // file scheme + [TestMethod] + [DataRow("http://auth.example.com/token", false)] // http on secure registry + [DataRow("ftp://auth.example.com/token", false)] // unsupported scheme on secure registry + [DataRow("ftp://auth.example.com/token", true)] // unsupported scheme stays rejected even when insecure + [DataRow("file:///etc/passwd", false)] // file scheme public void ValidateRealmUri_RejectsDisallowedSchemes(string realm, bool isInsecureRegistry) { - Assert.Throws(() => + Assert.ThrowsExactly(() => AuthHandshakeMessageHandler.ValidateRealmUri(realm, "registry.example.com", isInsecureRegistry)); } @@ -343,13 +346,13 @@ public void ValidateRealmUri_RejectsDisallowedSchemes(string realm, bool isInsec /// rather than producing an opaque /// downstream failure. /// - [Theory] - [InlineData("not a url")] - [InlineData("/relative/path")] - [InlineData("auth.example.com/token")] + [TestMethod] + [DataRow("not a url")] + [DataRow("/relative/path")] + [DataRow("auth.example.com/token")] public void ValidateRealmUri_RejectsRelativeOrUnparseableRealms(string realm) { - Assert.Throws(() => + Assert.ThrowsExactly(() => AuthHandshakeMessageHandler.ValidateRealmUri(realm, "registry.example.com", isInsecureRegistry: false)); } @@ -360,42 +363,42 @@ public void ValidateRealmUri_RejectsRelativeOrUnparseableRealms(string realm) /// hardening: Unicode-dot forms (U+FF0E, U+3002) that a runtime would resolve back to /// a blocked IPv4 literal are rejected even though they appear as DNS-typed hosts. /// - [Theory] + [TestMethod] // IPv4 ranges that must be blocked. - [InlineData("https://127.0.0.1/token")] // loopback - [InlineData("https://127.5.6.7/token")] // 127/8 - [InlineData("https://0.0.0.0/token")] // unspecified - [InlineData("https://10.0.0.5/token")] // private - [InlineData("https://172.16.0.1/token")] // private - [InlineData("https://172.31.255.255/token")] // private edge - [InlineData("https://192.168.1.5/token")] // private - [InlineData("https://169.254.169.254/token")] // link-local (cloud metadata) - [InlineData("https://224.0.0.1/token")] // link-local multicast + [DataRow("https://127.0.0.1/token")] // loopback + [DataRow("https://127.5.6.7/token")] // 127/8 + [DataRow("https://0.0.0.0/token")] // unspecified + [DataRow("https://10.0.0.5/token")] // private + [DataRow("https://172.16.0.1/token")] // private + [DataRow("https://172.31.255.255/token")] // private edge + [DataRow("https://192.168.1.5/token")] // private + [DataRow("https://169.254.169.254/token")] // link-local (cloud metadata) + [DataRow("https://224.0.0.1/token")] // link-local multicast // IPv6 ranges that must be blocked. - [InlineData("https://[::1]/token")] // loopback - [InlineData("https://[::]/token")] // unspecified - [InlineData("https://[fe80::1]/token")] // link-local - [InlineData("https://[ff02::1]/token")] // link-local multicast - [InlineData("https://[fc00::1]/token")] // unique-local (private) - [InlineData("https://[fec0::1]/token")] // site-local (deprecated, still treated as private) - [InlineData("https://[::ffff:127.0.0.1]/token")] // IPv4-mapped IPv6 of loopback - [InlineData("https://[::ffff:169.254.169.254]/token")] // IPv4-mapped IPv6 of metadata + [DataRow("https://[::1]/token")] // loopback + [DataRow("https://[::]/token")] // unspecified + [DataRow("https://[fe80::1]/token")] // link-local + [DataRow("https://[ff02::1]/token")] // link-local multicast + [DataRow("https://[fc00::1]/token")] // unique-local (private) + [DataRow("https://[fec0::1]/token")] // site-local (deprecated, still treated as private) + [DataRow("https://[::ffff:127.0.0.1]/token")] // IPv4-mapped IPv6 of loopback + [DataRow("https://[::ffff:169.254.169.254]/token")] // IPv4-mapped IPv6 of metadata // Unicode-dot canonicalization bypasses: U+FF0E (fullwidth full stop) and U+3002 // (ideographic full stop) appear as DNS to Uri.HostNameType but Uri.IdnHost canonicalizes // them back to the underlying IPv4 literal that HttpClient actually connects to. - [InlineData("https://127\uFF0E0\uFF0E0\uFF0E1/token")] - [InlineData("https://169\uFF0E254\uFF0E169\uFF0E254/token")] - [InlineData("https://10\uFF0E0\uFF0E0\uFF0E1/token")] - [InlineData("https://127\u30020\u30020\u30021/token")] + [DataRow("https://127\uFF0E0\uFF0E0\uFF0E1/token")] + [DataRow("https://169\uFF0E254\uFF0E169\uFF0E254/token")] + [DataRow("https://10\uFF0E0\uFF0E0\uFF0E1/token")] + [DataRow("https://127\u30020\u30020\u30021/token")] // FQDN root-zone trailing dot: Uri.IdnHost preserves the trailing "." so neither // IPAddress.TryParse nor a plain DNS name match would catch these without normalization, // but every resolver treats "127.0.0.1." as equivalent to "127.0.0.1". - [InlineData("https://127.0.0.1./token")] - [InlineData("https://169.254.169.254./token")] - [InlineData("https://10.0.0.5./token")] + [DataRow("https://127.0.0.1./token")] + [DataRow("https://169.254.169.254./token")] + [DataRow("https://10.0.0.5./token")] public void ValidateRealmUri_RejectsBlockedIpLiterals_OnSecureRegistry(string realm) { - Assert.Throws(() => + Assert.ThrowsExactly(() => AuthHandshakeMessageHandler.ValidateRealmUri(realm, "registry.example.com", isInsecureRegistry: false)); } @@ -405,21 +408,21 @@ public void ValidateRealmUri_RejectsBlockedIpLiterals_OnSecureRegistry(string re /// host, and lookalike hostnames such as localhost.example.com do not trigger /// the RFC 6761 localhost-loopback exception. /// - [Theory] + [TestMethod] // Even for insecure registries, IP-literal realm hosts are blocked unless they match the registry host. - [InlineData("https://169.254.169.254/token", "192.168.1.5:5000")] - [InlineData("https://10.0.0.5/token", "192.168.1.5:5000")] - [InlineData("https://[::1]/token", "192.168.1.5:5000")] + [DataRow("https://169.254.169.254/token", "192.168.1.5:5000")] + [DataRow("https://10.0.0.5/token", "192.168.1.5:5000")] + [DataRow("https://[::1]/token", "192.168.1.5:5000")] // The localhost exception only widens loopback (RFC 6761) - non-loopback blocked IPs // are still rejected even when the registry name is "localhost". - [InlineData("https://169.254.169.254/token", "localhost:5000")] - [InlineData("https://192.168.1.5/token", "localhost:5000")] + [DataRow("https://169.254.169.254/token", "localhost:5000")] + [DataRow("https://192.168.1.5/token", "localhost:5000")] // A name that merely contains "localhost" but isn't localhost or a *.localhost subdomain // does not get the exception (e.g. "localhost.example.com" is a public DNS name). - [InlineData("https://127.0.0.1/token", "localhost.example.com:5000")] + [DataRow("https://127.0.0.1/token", "localhost.example.com:5000")] public void ValidateRealmUri_RejectsBlockedIpLiterals_OnInsecureRegistryWhenHostsDiffer(string realm, string registryName) { - Assert.Throws(() => + Assert.ThrowsExactly(() => AuthHandshakeMessageHandler.ValidateRealmUri(realm, registryName, isInsecureRegistry: true)); } @@ -428,24 +431,24 @@ public void ValidateRealmUri_RejectsBlockedIpLiterals_OnInsecureRegistryWhenHost /// registry is insecure and the realm host refers to the same machine as the registry /// host. /// - [Theory] + [TestMethod] // Exception: when registry is insecure AND realm host equals the registry host (port-independent), // an otherwise-blocked IP literal is permitted to support legitimate private/on-prem dev registries. - [InlineData("http://192.168.1.5/auth", "192.168.1.5")] - [InlineData("http://192.168.1.5:6000/auth", "192.168.1.5:5000")] // same host, different port - [InlineData("https://192.168.1.5/auth", "192.168.1.5:5000")] - [InlineData("http://127.0.0.1:7000/auth", "127.0.0.1:5000")] - [InlineData("https://[::1]:7000/auth", "[::1]:5000")] + [DataRow("http://192.168.1.5/auth", "192.168.1.5")] + [DataRow("http://192.168.1.5:6000/auth", "192.168.1.5:5000")] // same host, different port + [DataRow("https://192.168.1.5/auth", "192.168.1.5:5000")] + [DataRow("http://127.0.0.1:7000/auth", "127.0.0.1:5000")] + [DataRow("https://[::1]:7000/auth", "[::1]:5000")] // RFC 6761: "localhost" (and *.localhost subdomains) are reserved for loopback, so a // localhost-named registry returning a loopback IP-literal realm is legitimate. - [InlineData("http://127.0.0.1:5000/auth", "localhost:5000")] - [InlineData("http://127.0.0.1:5000/auth", "LocalHost:5000")] // case-insensitive - [InlineData("http://[::1]:5000/auth", "localhost:5000")] - [InlineData("http://127.0.0.1:5000/auth", "registry.localhost:5000")] + [DataRow("http://127.0.0.1:5000/auth", "localhost:5000")] + [DataRow("http://127.0.0.1:5000/auth", "LocalHost:5000")] // case-insensitive + [DataRow("http://[::1]:5000/auth", "localhost:5000")] + [DataRow("http://127.0.0.1:5000/auth", "registry.localhost:5000")] public void ValidateRealmUri_AllowsMatchingIpLiteralWhenInsecure(string realm, string registryName) { Uri uri = AuthHandshakeMessageHandler.ValidateRealmUri(realm, registryName, isInsecureRegistry: true); - Assert.Equal(realm, uri.AbsoluteUri); + Assert.AreEqual(realm, uri.AbsoluteUri); } /// @@ -455,28 +458,28 @@ public void ValidateRealmUri_AllowsMatchingIpLiteralWhenInsecure(string realm, s /// 127.0.0.1 even though they appear as DNS to Uri.HostNameType. Both the /// secure-registry case and the insecure-but-non-matching-registry case are covered. /// - [Theory] + [TestMethod] // Secure registry: loopback-name realms are always rejected. - [InlineData("https://localhost/token", "registry.example.com", false)] - [InlineData("https://localhost:5000/token", "registry.example.com", false)] - [InlineData("https://foo.localhost/token", "registry.example.com", false)] - [InlineData("https://LOCALHOST/token", "registry.example.com", false)] // case-insensitive + [DataRow("https://localhost/token", "registry.example.com", false)] + [DataRow("https://localhost:5000/token", "registry.example.com", false)] + [DataRow("https://foo.localhost/token", "registry.example.com", false)] + [DataRow("https://LOCALHOST/token", "registry.example.com", false)] // case-insensitive // FQDN root-zone trailing dot: "localhost." is equivalent to "localhost" to every // resolver. Uri.IdnHost preserves the dot so the validator must normalize it away. - [InlineData("https://localhost./token", "registry.example.com", false)] - [InlineData("https://foo.localhost./token", "registry.example.com", false)] + [DataRow("https://localhost./token", "registry.example.com", false)] + [DataRow("https://foo.localhost./token", "registry.example.com", false)] // Unicode trailing dot (U+3002 ideographic full stop) - Uri.IdnHost canonicalizes // it to "localhost.", so it must be caught by the same trailing-dot normalization. - [InlineData("https://localhost\u3002/token", "registry.example.com", false)] + [DataRow("https://localhost\u3002/token", "registry.example.com", false)] // Insecure registry: still rejected when registry isn't a loopback-equivalent host. - [InlineData("https://localhost/token", "192.168.1.5:5000", true)] - [InlineData("http://localhost/token", "192.168.1.5:5000", true)] + [DataRow("https://localhost/token", "192.168.1.5:5000", true)] + [DataRow("http://localhost/token", "192.168.1.5:5000", true)] // Lookalike that isn't actually localhost: "localhost.example.com" is a public DNS // name, so the registry doesn't match the loopback exception either. - [InlineData("http://localhost/token", "localhost.example.com:5000", true)] + [DataRow("http://localhost/token", "localhost.example.com:5000", true)] public void ValidateRealmUri_RejectsLoopbackDnsNameRealm(string realm, string registryName, bool isInsecureRegistry) { - Assert.Throws(() => + Assert.ThrowsExactly(() => AuthHandshakeMessageHandler.ValidateRealmUri(realm, registryName, isInsecureRegistry)); } @@ -487,18 +490,18 @@ public void ValidateRealmUri_RejectsLoopbackDnsNameRealm(string realm, string re /// Mirrors for the /// case where the realm side uses a DNS name instead of an IP literal. /// - [Theory] - [InlineData("http://localhost:5000/auth", "localhost:5000")] - [InlineData("https://localhost:5000/auth", "localhost:5000")] - [InlineData("http://localhost:7000/auth", "localhost:5000")] // port-independent - [InlineData("http://foo.localhost:5000/auth", "localhost:5000")] // *.localhost realm - [InlineData("http://localhost:5000/auth", "registry.localhost:5000")] // *.localhost registry - [InlineData("http://localhost:5000/auth", "127.0.0.1:5000")] // registry is loopback IP literal - [InlineData("http://localhost:5000/auth", "[::1]:5000")] // registry is IPv6 loopback literal + [TestMethod] + [DataRow("http://localhost:5000/auth", "localhost:5000")] + [DataRow("https://localhost:5000/auth", "localhost:5000")] + [DataRow("http://localhost:7000/auth", "localhost:5000")] // port-independent + [DataRow("http://foo.localhost:5000/auth", "localhost:5000")] // *.localhost realm + [DataRow("http://localhost:5000/auth", "registry.localhost:5000")] // *.localhost registry + [DataRow("http://localhost:5000/auth", "127.0.0.1:5000")] // registry is loopback IP literal + [DataRow("http://localhost:5000/auth", "[::1]:5000")] // registry is IPv6 loopback literal public void ValidateRealmUri_AllowsLoopbackDnsNameRealm_WhenInsecureAndRegistryIsLoopback(string realm, string registryName) { Uri uri = AuthHandshakeMessageHandler.ValidateRealmUri(realm, registryName, isInsecureRegistry: true); - Assert.Equal(realm, uri.AbsoluteUri); + Assert.AreEqual(realm, uri.AbsoluteUri); } /// @@ -506,14 +509,14 @@ public void ValidateRealmUri_AllowsLoopbackDnsNameRealm_WhenInsecureAndRegistryI /// IP literals) pass all guards regardless of the insecure flag - this is the /// expected shape for any production token endpoint. /// - [Theory] - [InlineData("https://auth.example.com/token", "registry.example.com", false)] - [InlineData("https://auth.docker.io/token", "registry-1.docker.io", false)] // real Docker Hub realm shape - [InlineData("http://auth.example.com:8080/token", "registry.example.com:5000", true)] + [TestMethod] + [DataRow("https://auth.example.com/token", "registry.example.com", false)] + [DataRow("https://auth.docker.io/token", "registry-1.docker.io", false)] // real Docker Hub realm shape + [DataRow("http://auth.example.com:8080/token", "registry.example.com:5000", true)] public void ValidateRealmUri_AllowsPublicDnsRealms(string realm, string registryName, bool isInsecureRegistry) { Uri uri = AuthHandshakeMessageHandler.ValidateRealmUri(realm, registryName, isInsecureRegistry); - Assert.Equal(realm, uri.AbsoluteUri); + Assert.AreEqual(realm, uri.AbsoluteUri); } /// @@ -522,7 +525,7 @@ public void ValidateRealmUri_AllowsPublicDnsRealms(string realm, string registry /// and dispatch zero requests to the /// realm host. /// - [Fact] + [TestMethod] public async Task SendAsync_ThrowsOnInvalidBearerRealm_WithoutTokenRequest() { // Use a unique registry name to avoid contamination from the static auth header cache. @@ -556,11 +559,11 @@ HttpResponseMessage Server(HttpRequestMessage request) RegistryMode.Pull); using var httpClient = new HttpClient(authHandler); - await Assert.ThrowsAsync(() => - httpClient.GetAsync(requestUrl, TestContext.Current.CancellationToken)); + await Assert.ThrowsExactlyAsync(() => + httpClient.GetAsync(requestUrl, TestContext.CancellationToken)); // The handler must not have followed the malicious realm. - Assert.Equal(0, tokenRequestCount); + Assert.AreEqual(0, tokenRequestCount); } } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ContainerHelpersTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ContainerHelpersTests.cs index 27d2b7441bf4..847f9eaf7922 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ContainerHelpersTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ContainerHelpersTests.cs @@ -3,150 +3,151 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class ContainerHelpersTests { private const string DefaultRegistry = "docker.io"; - [Theory] + [TestMethod] // Valid Tests - [InlineData("mcr.microsoft.com", true)] - [InlineData("mcr.microsoft.com:5001", true)] // Registries can have ports - [InlineData("docker.io", true)] // default docker registry is considered valid + [DataRow("mcr.microsoft.com", true)] + [DataRow("mcr.microsoft.com:5001", true)] // Registries can have ports + [DataRow("docker.io", true)] // default docker registry is considered valid // // Invalid tests - [InlineData("mcr.mi-=crosoft.com", false)] // invalid url - [InlineData("mcr.microsoft.com/", false)] // invalid url + [DataRow("mcr.mi-=crosoft.com", false)] // invalid url + [DataRow("mcr.microsoft.com/", false)] // invalid url public void IsValidRegistry(string registry, bool expectedReturn) { Console.WriteLine($"Domain pattern is '{ReferenceParser.AnchoredDomainRegexp.ToString()}'"); - Assert.Equal(expectedReturn, ContainerHelpers.IsValidRegistry(registry)); + Assert.AreEqual(expectedReturn, ContainerHelpers.IsValidRegistry(registry)); } - [Theory] - [InlineData("mcr.microsoft.com/dotnet/runtime@sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true, "mcr.microsoft.com", "dotnet/runtime", null, "sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true)] + [TestMethod] + [DataRow("mcr.microsoft.com/dotnet/runtime@sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true, "mcr.microsoft.com", "dotnet/runtime", null, "sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true)] // Handle both tag and digest - [InlineData("mcr.microsoft.com/dotnet/runtime:6.0@sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true, "mcr.microsoft.com", "dotnet/runtime", "6.0", "sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true)] - [InlineData("mcr.microsoft.com/dotnet/runtime:6.0", true, "mcr.microsoft.com", "dotnet/runtime", "6.0", null, true)] - [InlineData("mcr.microsoft.com/dotnet/runtime", true, "mcr.microsoft.com", "dotnet/runtime", null, null, true)] - [InlineData("mcr.microsoft.com/", false, null, null, null, null, false)] // no image = nothing resolves + [DataRow("mcr.microsoft.com/dotnet/runtime:6.0@sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true, "mcr.microsoft.com", "dotnet/runtime", "6.0", "sha256:6cec36412a215aad2a033cfe259890482be0a1dcb680e81fccc393b2d4069455", true)] + [DataRow("mcr.microsoft.com/dotnet/runtime:6.0", true, "mcr.microsoft.com", "dotnet/runtime", "6.0", null, true)] + [DataRow("mcr.microsoft.com/dotnet/runtime", true, "mcr.microsoft.com", "dotnet/runtime", null, null, true)] + [DataRow("mcr.microsoft.com/", false, null, null, null, null, false)] // no image = nothing resolves // Ports tag along - [InlineData("mcr.microsoft.com:54/dotnet/runtime", true, "mcr.microsoft.com:54", "dotnet/runtime", null, null, true)] + [DataRow("mcr.microsoft.com:54/dotnet/runtime", true, "mcr.microsoft.com:54", "dotnet/runtime", null, null, true)] // Even if nonsensical - [InlineData("mcr.microsoft.com:0/dotnet/runtime", true, "mcr.microsoft.com:0", "dotnet/runtime", null, null, true)] + [DataRow("mcr.microsoft.com:0/dotnet/runtime", true, "mcr.microsoft.com:0", "dotnet/runtime", null, null, true)] // We don't allow hosts with missing ports when a port is anticipated - [InlineData("mcr.microsoft.com:/dotnet/runtime", false, null, null, null, null, false)] + [DataRow("mcr.microsoft.com:/dotnet/runtime", false, null, null, null, null, false)] // Use default registry when no registry specified. - [InlineData("ubuntu:jammy", true, DefaultRegistry, "library/ubuntu", "jammy", null, false)] - [InlineData("ubuntu/runtime:jammy", true, DefaultRegistry, "ubuntu/runtime", "jammy", null, false)] + [DataRow("ubuntu:jammy", true, DefaultRegistry, "library/ubuntu", "jammy", null, false)] + [DataRow("ubuntu/runtime:jammy", true, DefaultRegistry, "ubuntu/runtime", "jammy", null, false)] // Alias 'docker.io' to Docker registry. - [InlineData("docker.io/ubuntu:jammy", true, DefaultRegistry, "library/ubuntu", "jammy", null, true)] - [InlineData("docker.io/ubuntu/runtime:jammy", true, DefaultRegistry, "ubuntu/runtime", "jammy", null, true)] + [DataRow("docker.io/ubuntu:jammy", true, DefaultRegistry, "library/ubuntu", "jammy", null, true)] + [DataRow("docker.io/ubuntu/runtime:jammy", true, DefaultRegistry, "ubuntu/runtime", "jammy", null, true)] // 'localhost' registry. - [InlineData("localhost/ubuntu:jammy", true, "localhost", "ubuntu", "jammy", null, true)] + [DataRow("localhost/ubuntu:jammy", true, "localhost", "ubuntu", "jammy", null, true)] public void TryParseFullyQualifiedContainerName(string fullyQualifiedName, bool expectedReturn, string? expectedRegistry, string? expectedImage, string? expectedTag, string? expectedDigest, bool expectedIsRegistrySpecified) { - Assert.Equal(expectedReturn, ContainerHelpers.TryParseFullyQualifiedContainerName(fullyQualifiedName, out string? containerReg, out string? containerName, out string? containerTag, out string? containerDigest, out bool isRegistrySpecified)); - Assert.Equal(expectedRegistry, containerReg); - Assert.Equal(expectedImage, containerName); - Assert.Equal(expectedTag, containerTag); - Assert.Equal(expectedDigest, containerDigest); - Assert.Equal(expectedIsRegistrySpecified, isRegistrySpecified); + Assert.AreEqual(expectedReturn, ContainerHelpers.TryParseFullyQualifiedContainerName(fullyQualifiedName, out string? containerReg, out string? containerName, out string? containerTag, out string? containerDigest, out bool isRegistrySpecified)); + Assert.AreEqual(expectedRegistry, containerReg); + Assert.AreEqual(expectedImage, containerName); + Assert.AreEqual(expectedTag, containerTag); + Assert.AreEqual(expectedDigest, containerDigest); + Assert.AreEqual(expectedIsRegistrySpecified, isRegistrySpecified); } - [Theory] - [InlineData("dotnet/runtime", true)] - [InlineData("foo/bar", true)] - [InlineData("registry", true)] - [InlineData("-foo/bar", false)] - [InlineData(".foo/bar", false)] - [InlineData("_foo/bar", false)] - [InlineData("foo/bar-", false)] - [InlineData("foo/bar.", false)] - [InlineData("foo/bar_", false)] - [InlineData("--------", false)] + [TestMethod] + [DataRow("dotnet/runtime", true)] + [DataRow("foo/bar", true)] + [DataRow("registry", true)] + [DataRow("-foo/bar", false)] + [DataRow(".foo/bar", false)] + [DataRow("_foo/bar", false)] + [DataRow("foo/bar-", false)] + [DataRow("foo/bar.", false)] + [DataRow("foo/bar_", false)] + [DataRow("--------", false)] public void IsValidImageName(string imageName, bool expectedReturn) { - Assert.Equal(expectedReturn, ContainerHelpers.IsValidImageName(imageName)); + Assert.AreEqual(expectedReturn, ContainerHelpers.IsValidImageName(imageName)); } - [Theory] - [InlineData("0aa", "0aa", null, null)] - [InlineData("9zz", "9zz", null, null)] - [InlineData("aa0", "aa0", null, null)] - [InlineData("zz9", "zz9", null, null)] - [InlineData("runtime", "runtime", null, null)] - [InlineData("dotnet_runtime", "dotnet_runtime", null, null)] - [InlineData("dotnet-runtime", "dotnet-runtime", null, null)] - [InlineData("dotnet/runtime", "dotnet/runtime", null, null)] - [InlineData("dotnet runtime", "dotnet-runtime", "NormalizedContainerName", null)] - [InlineData("Api", "api", "NormalizedContainerName", null)] - [InlineData("API", "api", "NormalizedContainerName", null)] - [InlineData("$runtime", null, null, "InvalidImageName_NonAlphanumericStartCharacter")] - [InlineData("-%", null, null, "InvalidImageName_NonAlphanumericStartCharacter")] + [TestMethod] + [DataRow("0aa", "0aa", null, null)] + [DataRow("9zz", "9zz", null, null)] + [DataRow("aa0", "aa0", null, null)] + [DataRow("zz9", "zz9", null, null)] + [DataRow("runtime", "runtime", null, null)] + [DataRow("dotnet_runtime", "dotnet_runtime", null, null)] + [DataRow("dotnet-runtime", "dotnet-runtime", null, null)] + [DataRow("dotnet/runtime", "dotnet/runtime", null, null)] + [DataRow("dotnet runtime", "dotnet-runtime", "NormalizedContainerName", null)] + [DataRow("Api", "api", "NormalizedContainerName", null)] + [DataRow("API", "api", "NormalizedContainerName", null)] + [DataRow("$runtime", null, null, "InvalidImageName_NonAlphanumericStartCharacter")] + [DataRow("-%", null, null, "InvalidImageName_NonAlphanumericStartCharacter")] public void IsValidRepositoryName(string containerRepository, string? expectedNormalized, string? expectedWarning, string? expectedError) { var actual = ContainerHelpers.NormalizeRepository(containerRepository); - Assert.Equal(expectedNormalized, actual.normalizedImageName); - Assert.Equal(expectedWarning, actual.normalizationWarning?.Item1); - Assert.Equal(expectedError, actual.normalizationError?.Item1); + Assert.AreEqual(expectedNormalized, actual.normalizedImageName); + Assert.AreEqual(expectedWarning, actual.normalizationWarning?.Item1); + Assert.AreEqual(expectedError, actual.normalizationError?.Item1); } - [Theory] - [InlineData("6.0", true)] // baseline - [InlineData("5.2-asd123", true)] // with commit hash - [InlineData(".6.0", false)] // starts with . - [InlineData("-6.0", false)] // starts with - - [InlineData("---", false)] // malformed + [TestMethod] + [DataRow("6.0", true)] // baseline + [DataRow("5.2-asd123", true)] // with commit hash + [DataRow(".6.0", false)] // starts with . + [DataRow("-6.0", false)] // starts with - + [DataRow("---", false)] // malformed public void IsValidImageTag(string imageTag, bool expectedReturn) { - Assert.Equal(expectedReturn, ContainerHelpers.IsValidImageTag(imageTag)); + Assert.AreEqual(expectedReturn, ContainerHelpers.IsValidImageTag(imageTag)); } - [Fact] + [TestMethod] public void IsValidImageTag_InvalidLength() { - Assert.False(ContainerHelpers.IsValidImageTag(new string('a', 129))); + Assert.IsFalse(ContainerHelpers.IsValidImageTag(new string('a', 129))); } - [Theory] - [InlineData("80/tcp", true, 80, PortType.tcp, null)] - [InlineData("80", true, 80, PortType.tcp, null)] - [InlineData("125/dup", false, 125, PortType.tcp, ContainerHelpers.ParsePortError.InvalidPortType)] - [InlineData("invalidNumber", false, null, null, ContainerHelpers.ParsePortError.InvalidPortNumber)] - [InlineData("welp/unknowntype", false, null, null, (ContainerHelpers.ParsePortError)6)] - [InlineData("a/b/c", false, null, null, ContainerHelpers.ParsePortError.UnknownPortFormat)] - [InlineData("/tcp", false, null, null, ContainerHelpers.ParsePortError.MissingPortNumber)] + [TestMethod] + [DataRow("80/tcp", true, 80, PortType.tcp, null)] + [DataRow("80", true, 80, PortType.tcp, null)] + [DataRow("125/dup", false, 125, PortType.tcp, ContainerHelpers.ParsePortError.InvalidPortType)] + [DataRow("invalidNumber", false, null, null, ContainerHelpers.ParsePortError.InvalidPortNumber)] + [DataRow("welp/unknowntype", false, null, null, (ContainerHelpers.ParsePortError)6)] + [DataRow("a/b/c", false, null, null, ContainerHelpers.ParsePortError.UnknownPortFormat)] + [DataRow("/tcp", false, null, null, ContainerHelpers.ParsePortError.MissingPortNumber)] public void CanParsePort(string input, bool shouldParse, int? expectedPortNumber, PortType? expectedType, ContainerHelpers.ParsePortError? expectedError) { var parseSuccess = ContainerHelpers.TryParsePort(input, out var port, out var errors); - Assert.Equal(shouldParse, parseSuccess); + Assert.AreEqual(shouldParse, parseSuccess); if (shouldParse) { - Assert.NotNull(port); - Assert.Equal(port.Value.Number, expectedPortNumber); - Assert.Equal(port.Value.Type, expectedType); + Assert.IsNotNull(port); + Assert.AreEqual(expectedPortNumber, port.Value.Number); + Assert.AreEqual(expectedType, port.Value.Type); } else { - Assert.Null(port); - Assert.NotNull(errors); - Assert.Equal(expectedError, errors); + Assert.IsNull(port); + Assert.IsNotNull(errors); + Assert.AreEqual(expectedError, errors); } } - [Theory] - [InlineData("FOO", true)] - [InlineData("foo_bar", true)] - [InlineData("foo-bar", false)] - [InlineData("foo.bar", false)] - [InlineData("foo bar", false)] - [InlineData("1_NAME", false)] - [InlineData("ASPNETCORE_URLS", true)] - [InlineData("ASPNETCORE_URLS2", true)] + [TestMethod] + [DataRow("FOO", true)] + [DataRow("foo_bar", true)] + [DataRow("foo-bar", false)] + [DataRow("foo.bar", false)] + [DataRow("foo bar", false)] + [DataRow("1_NAME", false)] + [DataRow("ASPNETCORE_URLS", true)] + [DataRow("ASPNETCORE_URLS2", true)] public void CanRecognizeEnvironmentVariableNames(string envVarName, bool isValid) { var success = ContainerHelpers.IsValidEnvironmentVariable(envVarName); - Assert.Equal(isValid, success); + Assert.AreEqual(isValid, success); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ContentStoreTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ContentStoreTests.cs index f968e35aecd4..8c8714b03935 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ContentStoreTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ContentStoreTests.cs @@ -3,11 +3,12 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class ContentStoreTests { - [Theory] - [InlineData("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] - [InlineData("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [TestMethod] + [DataRow("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] + [DataRow("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] public void PathForDescriptor_AcceptsWellFormedDigest(string digest) { Descriptor descriptor = CreateDescriptorWithDigest(digest); @@ -15,14 +16,14 @@ public void PathForDescriptor_AcceptsWellFormedDigest(string digest) Assert.StartsWith(ContentStore.ContentRoot, path); } - [Theory] - [InlineData("")] // empty string - [InlineData("sha256:../..\\xyz_not_hex!!")] // non-hex characters - [InlineData("c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // missing algorithm prefix + [TestMethod] + [DataRow("")] // empty string + [DataRow("sha256:../..\\xyz_not_hex!!")] // non-hex characters + [DataRow("c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // missing algorithm prefix public void PathForDescriptor_RejectsInvalidDigest(string digest) { Descriptor descriptor = CreateDescriptorWithDigest(digest); - Assert.Throws(() => + Assert.ThrowsExactly(() => ContentStore.PathForDescriptor(descriptor)); } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/CreateNewImageTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/CreateNewImageTests.cs index 3faeedc3f8ce..8de0b7c5c1ae 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/CreateNewImageTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/CreateNewImageTests.cs @@ -8,68 +8,69 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class CreateNewImageTests { - [Theory] + [TestMethod] // Entrypoint, backwards compatibility. - [InlineData("", "entrypointArg", "appCommand", "", "", null, new[] { "appCommand" }, new[] { "entrypointArg" })] + [DataRow("", "entrypointArg", "appCommand", "", "", null, new[] { "appCommand" }, new[] { "entrypointArg" })] // When no entrypoint is specified, emit the AppCommand as the Entrypoint. - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "appCommand", "appCommandArgs" }, new[] { "defaultArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "appCommand", "appCommandArgs" }, new[] { "defaultArgs" })] // Set all properties. When an entrypoint is specified, emit the AppCommand as Cmd. - [InlineData("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", + [DataRow("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] public void EntrypointAndCmd_NoInstruction(string entrypoint, string entrypointArgs, string appCommand, string appCommandArgs, string defaultArgs, string? baseImageEntrypoint, string[]? expectedEntrypoint, string[]? expectedCmd) => ValidateArgsAndCmd("", entrypoint, entrypointArgs, appCommand, appCommandArgs, defaultArgs, baseImageEntrypoint, expectedEntrypoint, expectedCmd); - [Theory] + [TestMethod] // Set all properties. - [InlineData("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", + [DataRow("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] // No Entrypoint, AppCommand specified, base entrypoint is preserved. - [InlineData("", "", "appCommand", "", "", "", null, new[] { "appCommand" })] - [InlineData("", "", "appCommand", "appCommandArgs", "", "", null, new[] { "appCommand", "appCommandArgs" })] - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] - [InlineData("", "", "appCommand", "", "", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand" })] - [InlineData("", "", "appCommand", "appCommandArgs", "", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand", "appCommandArgs" })] - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] + [DataRow("", "", "appCommand", "", "", "", null, new[] { "appCommand" })] + [DataRow("", "", "appCommand", "appCommandArgs", "", "", null, new[] { "appCommand", "appCommandArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] + [DataRow("", "", "appCommand", "", "", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand" })] + [DataRow("", "", "appCommand", "appCommandArgs", "", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand", "appCommandArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "baseEntrypoint" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] // No Entrypoint, AppCommand specified, 'dotnet' base entrypoint is ignored. - [InlineData("", "", "appCommand", "", "", "dotnet", null, new[] { "appCommand" })] - [InlineData("", "", "appCommand", "appCommandArgs", "", "dotnet", null, new[] { "appCommand", "appCommandArgs" })] - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "dotnet", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] + [DataRow("", "", "appCommand", "", "", "dotnet", null, new[] { "appCommand" })] + [DataRow("", "", "appCommand", "appCommandArgs", "", "dotnet", null, new[] { "appCommand", "appCommandArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "dotnet", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] // No Entrypoint, AppCommand specified, '/usr/bin/dotnet' base entrypoint is ignored. - [InlineData("", "", "appCommand", "", "", "/usr/bin/dotnet", null, new[] { "appCommand" })] - [InlineData("", "", "appCommand", "appCommandArgs", "", "/usr/bin/dotnet", null, new[] { "appCommand", "appCommandArgs" })] - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "/usr/bin/dotnet", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] + [DataRow("", "", "appCommand", "", "", "/usr/bin/dotnet", null, new[] { "appCommand" })] + [DataRow("", "", "appCommand", "appCommandArgs", "", "/usr/bin/dotnet", null, new[] { "appCommand", "appCommandArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "/usr/bin/dotnet", null, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] public void EntrypointAndCmd_DefaultArgsInstruction(string entrypoint, string entrypointArgs, string appCommand, string appCommandArgs, string defaultArgs, string? baseImageEntrypoint, string[]? expectedEntrypoint, string[]? expectedCmd) => ValidateArgsAndCmd("DefaultArgs", entrypoint, entrypointArgs, appCommand, appCommandArgs, defaultArgs, baseImageEntrypoint, expectedEntrypoint, expectedCmd); - [Theory] + [TestMethod] // Set all properties except entrypoint and entrypointArgs. - [InlineData("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "appCommand", "appCommandArgs" }, new[] { "defaultArgs" })] + [DataRow("", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "appCommand", "appCommandArgs" }, new[] { "defaultArgs" })] // Can't set entrypoint or entrypointArgs with instruction 'Entrypoint'. - [InlineData("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] - [InlineData("", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] - [InlineData("entrypoint", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] public void EntrypointAndCmd_EntrypointInstruction(string entrypoint, string entrypointArgs, string appCommand, string appCommandArgs, string defaultArgs, string? baseImageEntrypoint, string[]? expectedEntrypoint, string[]? expectedCmd) => ValidateArgsAndCmd("Entrypoint", entrypoint, entrypointArgs, appCommand, appCommandArgs, defaultArgs, baseImageEntrypoint, expectedEntrypoint, expectedCmd); - [Theory] + [TestMethod] // Set all properties except appCommand and appCommandArgs. - [InlineData("entrypoint", "entrypointArgs", "", "", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "defaultArgs" })] + [DataRow("entrypoint", "entrypointArgs", "", "", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "defaultArgs" })] // Can't set appCommand or appCommandArgs with instruction 'None'. - [InlineData("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] - [InlineData("entrypoint", "entrypointArgs", "", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] - [InlineData("entrypoint", "entrypointArgs", "appCommand", "", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "entrypointArgs", "", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "entrypointArgs", "appCommand", "", "defaultArgs", "baseEntrypoint", null, null)] public void EntrypointAndCmd_NoneInstruction(string entrypoint, string entrypointArgs, string appCommand, string appCommandArgs, string defaultArgs, string? baseImageEntrypoint, string[]? expectedEntrypoint, string[]? expectedCmd) => ValidateArgsAndCmd("None", entrypoint, entrypointArgs, appCommand, appCommandArgs, defaultArgs, baseImageEntrypoint, expectedEntrypoint, expectedCmd); - [Theory] + [TestMethod] // Set all properties accepted. - [InlineData("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] + [DataRow("entrypoint", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", new[] { "entrypoint", "entrypointArgs" }, new[] { "appCommand", "appCommandArgs", "defaultArgs" })] // Set all properties except entrypoint fails: can't set entrypointArgs without setting entrypoint. - [InlineData("", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("", "entrypointArgs", "appCommand", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] // Set all properties except appCommand fails: can't set appCommandArgs without setting appCommand. - [InlineData("entrypoint", "entrypointArgs", "", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] + [DataRow("entrypoint", "entrypointArgs", "", "appCommandArgs", "defaultArgs", "baseEntrypoint", null, null)] public void EntrypointAndCmd_RequiredProperties(string entrypoint, string entrypointArgs, string appCommand, string appCommandArgs, string defaultArgs, string? baseImageEntrypoint, string[]? expectedEntrypoint, string[]? expectedCmd) => ValidateArgsAndCmd("DefaultArgs", entrypoint, entrypointArgs, appCommand, appCommandArgs, defaultArgs, baseImageEntrypoint, expectedEntrypoint, expectedCmd); @@ -88,9 +89,9 @@ private static void ValidateArgsAndCmd(string appCommandInstruction, string entr (string[] imageEntrypoint, string[] imageCmd) = newImage.DetermineEntrypointAndCmd(baseImageEntrypoint?.Split(';', StringSplitOptions.RemoveEmptyEntries)); - Assert.Equal(newImage.Log.HasLoggedErrors, imageEntrypoint.Length == 0 && imageCmd.Length == 0); - Assert.Equal(expectedEntrypoint ?? Array.Empty(), imageEntrypoint); - Assert.Equal(expectedCmd ?? Array.Empty(), imageCmd); + Assert.AreEqual(newImage.Log.HasLoggedErrors, imageEntrypoint.Length == 0 && imageCmd.Length == 0); + Assert.AreSequenceEqual(expectedEntrypoint ?? Array.Empty(), imageEntrypoint); + Assert.AreSequenceEqual(expectedCmd ?? Array.Empty(), imageCmd); static ITaskItem[] CreateTaskItems(string value) => value.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(s => new TaskItem(s)).ToArray(); diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DescriptorTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DescriptorTests.cs index f689bd4c7944..2cc46b638fbe 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DescriptorTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DescriptorTests.cs @@ -5,9 +5,10 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class DescriptorTests { - [Fact] + [TestMethod] public void BasicConstructor() { Descriptor d = new( @@ -17,12 +18,12 @@ public void BasicConstructor() Console.WriteLine(JsonSerializer.Serialize(d, new JsonSerializerOptions { WriteIndented = true })); - Assert.Equal("application/vnd.oci.image.manifest.v1+json", d.MediaType); - Assert.Equal("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270", d.Digest); - Assert.Equal(7_682, d.Size); + Assert.AreEqual("application/vnd.oci.image.manifest.v1+json", d.MediaType); + Assert.AreEqual("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270", d.Digest); + Assert.AreEqual(7_682, d.Size); - Assert.Null(d.Annotations); - Assert.Null(d.Data); - Assert.Null(d.Urls); + Assert.IsNull(d.Annotations); + Assert.IsNull(d.Data); + Assert.IsNull(d.Urls); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DigestUtilsTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DigestUtilsTests.cs index c477b533d86e..545525a923ef 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DigestUtilsTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DigestUtilsTests.cs @@ -3,65 +3,66 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class DigestUtilsTests { - [Theory] - [InlineData("sha256:0000000000000000000000000000000000000000000000000000000000000000", "0000000000000000000000000000000000000000000000000000000000000000")] - [InlineData("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad", "c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] - [InlineData("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [TestMethod] + [DataRow("sha256:0000000000000000000000000000000000000000000000000000000000000000", "0000000000000000000000000000000000000000000000000000000000000000")] + [DataRow("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad", "c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] + [DataRow("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] public void GetEncoded_AcceptsValidDigest(string digest, string expectedEncoded) { string encoded = DigestUtils.GetEncoded(digest); - Assert.Equal(expectedEncoded, encoded); + Assert.AreEqual(expectedEncoded, encoded); } - [Theory] - [InlineData("")] // empty - [InlineData("sha256:")] // missing encoded value - [InlineData("sha256:../..\\xyz_not_hex!!")] // path traversal / invalid characters - [InlineData("c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // missing algorithm prefix - [InlineData("0000000c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // correct string length, no algorithm prefix - [InlineData("sha256:abc")] // too short for sha256 - [InlineData("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916a")] // 63 hex chars (1 too short for sha256) - [InlineData("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916adF")] // uppercase hex not allowed per OCI spec - [InlineData("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad000000000000")] // too long for sha256 - [InlineData("sha256:FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")] // uppercase hex not allowed per OCI spec - [InlineData("md5:5b0bcabd1ed22e9fb1310cf6")] // unregistered algorithm - [InlineData("sha512:abc")] // sha512 not currently supported - [InlineData("blake3:abc")] // blake3 not currently supported + [TestMethod] + [DataRow("")] // empty + [DataRow("sha256:")] // missing encoded value + [DataRow("sha256:../..\\xyz_not_hex!!")] // path traversal / invalid characters + [DataRow("c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // missing algorithm prefix + [DataRow("0000000c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad")] // correct string length, no algorithm prefix + [DataRow("sha256:abc")] // too short for sha256 + [DataRow("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916a")] // 63 hex chars (1 too short for sha256) + [DataRow("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916adF")] // uppercase hex not allowed per OCI spec + [DataRow("sha256:c5098cc7c2a2ad9bfc66e4c4cb242683a578e9d8f25fd8730b289dd5667916ad000000000000")] // too long for sha256 + [DataRow("sha256:FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")] // uppercase hex not allowed per OCI spec + [DataRow("md5:5b0bcabd1ed22e9fb1310cf6")] // unregistered algorithm + [DataRow("sha512:abc")] // sha512 not currently supported + [DataRow("blake3:abc")] // blake3 not currently supported public void GetEncoded_RejectsInvalidDigest(string digest) { - Assert.Throws(() => + Assert.ThrowsExactly(() => DigestUtils.GetEncoded(digest)); } - [Fact] + [TestMethod] public void ComputeSha256Digest_ReturnsCorrectDigest() { // Well-known: SHA-256 of empty string string digest = DigestUtils.ComputeSha256Digest(""); - Assert.Equal("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", digest); + Assert.AreEqual("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", digest); } - [Fact] + [TestMethod] public void ComputeSha256_ReturnsLowercaseHex() { string hash = DigestUtils.ComputeSha256(""); - Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash); + Assert.AreEqual("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash); } - [Fact] + [TestMethod] public void FormatSha256Digest_FormatsCorrectly() { string digest = DigestUtils.FormatSha256Digest("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); - Assert.Equal("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", digest); + Assert.AreEqual("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", digest); } - [Fact] + [TestMethod] public void ComputeSha256Digest_RoundTrips_Through_GetEncoded() { string digest = DigestUtils.ComputeSha256Digest("hello world"); string encoded = DigestUtils.GetEncoded(digest); - Assert.Equal(DigestUtils.ComputeSha256("hello world"), encoded); + Assert.AreEqual(DigestUtils.ComputeSha256("hello world"), encoded); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs index e0b2cd7458f7..cf5198f1d7ec 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DockerAvailableUtils.cs @@ -1,56 +1,32 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.CompilerServices; - namespace Microsoft.NET.Build.Containers.UnitTests; -public class DockerAvailableTheoryAttribute : TheoryAttribute +public sealed class DockerUnavailableCondition : ConditionBaseAttribute { - public static string LocalRegistry => DockerCliStatus.LocalRegistry; - - public DockerAvailableTheoryAttribute( - bool skipPodman = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public DockerUnavailableCondition() + : base(ConditionMode.Exclude) { - if (!DockerCliStatus.IsAvailable) - { - base.Skip = "Skipping test because Docker is not available on this host."; - } - - if (skipPodman && DockerCliStatus.Command == DockerCli.PodmanCommand) - { - base.Skip = $"Skipping test with {DockerCliStatus.Command} cli."; - } + IgnoreMessage = "Skipping test because Docker is not available on this host."; } + + public override string GroupName => nameof(DockerUnavailableCondition); + + public override bool IsConditionMet => !DockerCliStatus.IsAvailable; } -public class DockerAvailableFactAttribute : FactAttribute +public sealed class PodmanCliCondition : ConditionBaseAttribute { - public static string LocalRegistry => DockerCliStatus.LocalRegistry; - - public DockerAvailableFactAttribute( - bool skipPodman = false, - bool checkContainerdStoreAvailability = false, - [CallerFilePath] string? sourceFilePath = null, - [CallerLineNumber] int sourceLineNumber = 0) - : base(sourceFilePath, sourceLineNumber) + public PodmanCliCondition() + : base(ConditionMode.Exclude) { - if (!DockerCliStatus.IsAvailable) - { - base.Skip = "Skipping test because Docker is not available on this host."; - } - else if (checkContainerdStoreAvailability && DockerCliStatus.Command != DockerCli.PodmanCommand && !DockerCli.IsContainerdStoreEnabledForDocker()) - { - base.Skip = "Skipping test because Docker daemon is not using containerd as the storage driver."; - } - else if (skipPodman && DockerCliStatus.Command == DockerCli.PodmanCommand) - { - base.Skip = $"Skipping test with {DockerCliStatus.Command} cli."; - } + IgnoreMessage = "Skipping test with podman cli."; } + + public override string GroupName => nameof(PodmanCliCondition); + + public override bool IsConditionMet => DockerCliStatus.Command == DockerCli.PodmanCommand; } // tiny optimization - since there are many instances of this attribute we should only get diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs index 52848726311a..cf4b32c4471f 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs @@ -3,21 +3,17 @@ namespace Microsoft.NET.Build.Containers.UnitTests; -[CollectionDefinition("Daemon Tests")] -public class DaemonTestsCollection -{ -} - -[Collection("Daemon Tests")] +[TestClass] public class DockerDaemonTests : IDisposable { - private ITestOutputHelper _testOutput; - private readonly TestLoggerFactory _loggerFactory; + private TestLoggerFactory _loggerFactory = default!; + + public TestContext TestContext { get; set; } = default!; - public DockerDaemonTests(ITestOutputHelper testOutput) + [TestInitialize] + public void Initialize() { - _testOutput = testOutput; - _loggerFactory = new TestLoggerFactory(testOutput); + _loggerFactory = new TestLoggerFactory(TestContext); } public void Dispose() @@ -25,7 +21,9 @@ public void Dispose() _loggerFactory.Dispose(); } - [DockerAvailableFact(skipPodman: true)] // podman is a local cli not meant for connecting to remote Docker daemons. + [TestMethod] + [DockerUnavailableCondition] + [PodmanCliCondition] // podman is a local cli not meant for connecting to remote Docker daemons. public async Task Can_detect_when_no_daemon_is_running() { // mimic no daemon running by setting the DOCKER_HOST to a nonexistent socket @@ -33,7 +31,7 @@ public async Task Can_detect_when_no_daemon_is_running() { Environment.SetEnvironmentVariable("DOCKER_HOST", "tcp://123.123.123.123:12345"); var available = await new DockerCli(_loggerFactory).IsAvailableAsync(default).ConfigureAwait(false); - Assert.False(available, "No daemon should be listening at that port"); + Assert.IsFalse(available, "No daemon should be listening at that port"); } finally { @@ -41,10 +39,11 @@ public async Task Can_detect_when_no_daemon_is_running() } } - [DockerAvailableFact(Skip = "https://github.com/dotnet/sdk/issues/49502")] + [TestMethod] + [Ignore("https://github.com/dotnet/sdk/issues/49502")] public async Task Can_detect_when_daemon_is_running() { var available = await new DockerCli(_loggerFactory).IsAvailableAsync(default).ConfigureAwait(false); - Assert.True(available, "Should have found a working daemon"); + Assert.IsTrue(available, "Should have found a working daemon"); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/FallbackToHttpMessageHandlerTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/FallbackToHttpMessageHandlerTests.cs index 18a3a76c554a..d76afe36c207 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/FallbackToHttpMessageHandlerTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/FallbackToHttpMessageHandlerTests.cs @@ -11,17 +11,20 @@ namespace Microsoft.NET.Build.Containers.UnitTests { + [TestClass] public class FallbackToHttpMessageHandlerTests { - [Theory] - [InlineData("mcr.microsoft.com", 80)] - [InlineData("mcr.microsoft.com:443", 443)] - [InlineData("mcr.microsoft.com:80", 80)] - [InlineData("mcr.microsoft.com:5555", 5555)] - [InlineData("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]", 80)] - [InlineData("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:443", 443)] - [InlineData("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:80", 80)] - [InlineData("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:5555", 5555)] + public TestContext TestContext { get; set; } = default!; + + [TestMethod] + [DataRow("mcr.microsoft.com", 80)] + [DataRow("mcr.microsoft.com:443", 443)] + [DataRow("mcr.microsoft.com:80", 80)] + [DataRow("mcr.microsoft.com:5555", 5555)] + [DataRow("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]", 80)] + [DataRow("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:443", 443)] + [DataRow("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:80", 80)] + [DataRow("[2408:8120:245:49a0:f041:d7bb:bb13:5b64]:5555", 5555)] public async Task FallBackToHttpPortShouldAsExpected(string registry, int expectedPort) { var uri = new Uri($"https://{registry}"); @@ -50,8 +53,8 @@ public async Task FallBackToHttpPortShouldAsExpected(string registry, int expect NullLogger.Instance ); using var httpClient = new HttpClient(handler); - var response = await httpClient.GetAsync(uri, TestContext.Current.CancellationToken); - Assert.Equal(expectedPort, response.RequestMessage?.RequestUri?.Port); + var response = await httpClient.GetAsync(uri, TestContext.CancellationToken); + Assert.AreEqual(expectedPort, response.RequestMessage?.RequestUri?.Port); } private sealed class ServerMessageHandler : HttpMessageHandler diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs index adf8457962d2..7c017b865141 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs @@ -6,18 +6,28 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class ImageBuilderTests { - private readonly TestLoggerFactory _loggerFactory; + private TestLoggerFactory _loggerFactory = default!; private static readonly string StaticKnownDigestValue = "sha256:338c0b702da88157ba4bb706678e43346ece2e4397b888d59fb2d9f6113c8070"; - public ImageBuilderTests(ITestOutputHelper output) + public TestContext TestContext { get; set; } = default!; + + [TestInitialize] + public void Initialize() + { + _loggerFactory = new TestLoggerFactory(TestContext); + } + + [TestCleanup] + public void Cleanup() { - _loggerFactory = new TestLoggerFactory(output); + _loggerFactory.Dispose(); } - [Fact] + [TestMethod] public void CanAddLabelsToImage() { string simpleImageConfig = @@ -65,7 +75,7 @@ public void CanAddLabelsToImage() """; JsonNode? node = JsonNode.Parse(simpleImageConfig); - Assert.NotNull(node); + Assert.IsNotNull(node); ImageConfig baseConfig = new(node); @@ -77,14 +87,14 @@ public void CanAddLabelsToImage() JsonNode? result = JsonNode.Parse(readyImage); var resultLabels = result?["config"]?["Labels"] as JsonObject; - Assert.NotNull(resultLabels); + Assert.IsNotNull(resultLabels); - Assert.Equal(2, resultLabels.Count); - Assert.Equal("v1", resultLabels["testLabel1"]?.ToString()); - Assert.Equal("v2", resultLabels["testLabel2"]?.ToString()); + Assert.AreEqual(2, resultLabels.Count); + Assert.AreEqual("v1", resultLabels["testLabel1"]?.ToString()); + Assert.AreEqual("v2", resultLabels["testLabel2"]?.ToString()); } - [Fact] + [TestMethod] public void CanPreserveExistingLabels() { string simpleImageConfig = @@ -136,7 +146,7 @@ public void CanPreserveExistingLabels() """; JsonNode? node = JsonNode.Parse(simpleImageConfig); - Assert.NotNull(node); + Assert.IsNotNull(node); ImageConfig baseConfig = new(node); @@ -148,15 +158,15 @@ public void CanPreserveExistingLabels() JsonNode? result = JsonNode.Parse(readyImage); var resultLabels = result?["config"]?["Labels"] as JsonObject; - Assert.NotNull(resultLabels); + Assert.IsNotNull(resultLabels); - Assert.Equal(3, resultLabels.Count); - Assert.Equal("v1", resultLabels["testLabel1"]?.ToString()); - Assert.Equal("v2", resultLabels["existing2"]?.ToString()); - Assert.Equal("e1", resultLabels["existing"]?.ToString()); + Assert.AreEqual(3, resultLabels.Count); + Assert.AreEqual("v1", resultLabels["testLabel1"]?.ToString()); + Assert.AreEqual("v2", resultLabels["existing2"]?.ToString()); + Assert.AreEqual("e1", resultLabels["existing"]?.ToString()); } - [Fact] + [TestMethod] public void CanAddPortsToImage() { string simpleImageConfig = @@ -204,7 +214,7 @@ public void CanAddPortsToImage() """; JsonNode? node = JsonNode.Parse(simpleImageConfig); - Assert.NotNull(node); + Assert.IsNotNull(node); ImageConfig baseConfig = new(node); @@ -216,14 +226,14 @@ public void CanAddPortsToImage() JsonNode? result = JsonNode.Parse(readyImage); var resultPorts = result?["config"]?["ExposedPorts"] as JsonObject; - Assert.NotNull(resultPorts); + Assert.IsNotNull(resultPorts); - Assert.Equal(2, resultPorts.Count); - Assert.NotNull(resultPorts["6000/tcp"] as JsonObject); - Assert.NotNull(resultPorts["6010/udp"] as JsonObject); + Assert.AreEqual(2, resultPorts.Count); + Assert.IsNotNull(resultPorts["6000/tcp"] as JsonObject); + Assert.IsNotNull(resultPorts["6010/udp"] as JsonObject); } - [Fact] + [TestMethod] public void CanPreserveExistingPorts() { string simpleImageConfig = @@ -276,7 +286,7 @@ public void CanPreserveExistingPorts() """; JsonNode? node = JsonNode.Parse(simpleImageConfig); - Assert.NotNull(node); + Assert.IsNotNull(node); ImageConfig baseConfig = new(node); @@ -290,17 +300,17 @@ public void CanPreserveExistingPorts() JsonNode? result = JsonNode.Parse(readyImage); var resultPorts = result?["config"]?["ExposedPorts"] as JsonObject; - Assert.NotNull(resultPorts); - - Assert.Equal(5, resultPorts.Count); - Assert.NotNull(resultPorts["6000/tcp"] as JsonObject); - Assert.NotNull(resultPorts["6010/udp"] as JsonObject); - Assert.NotNull(resultPorts["6100/udp"] as JsonObject); - Assert.NotNull(resultPorts["6100/tcp"] as JsonObject); - Assert.NotNull(resultPorts["6200/tcp"] as JsonObject); + Assert.IsNotNull(resultPorts); + + Assert.AreEqual(5, resultPorts.Count); + Assert.IsNotNull(resultPorts["6000/tcp"] as JsonObject); + Assert.IsNotNull(resultPorts["6010/udp"] as JsonObject); + Assert.IsNotNull(resultPorts["6100/udp"] as JsonObject); + Assert.IsNotNull(resultPorts["6100/tcp"] as JsonObject); + Assert.IsNotNull(resultPorts["6200/tcp"] as JsonObject); } - [Fact] + [TestMethod] public void HistoryEntriesMatchNonEmptyLayers() { // Note how the base image config is already "corrupt" by having @@ -365,7 +375,7 @@ public void HistoryEntriesMatchNonEmptyLayers() """; JsonNode? node = JsonNode.Parse(simpleImageConfig); - Assert.NotNull(node); + Assert.IsNotNull(node); ImageConfig baseConfig = new(node); @@ -374,19 +384,19 @@ public void HistoryEntriesMatchNonEmptyLayers() JsonNode? result = JsonNode.Parse(readyImage); var historyNode = result?["history"]; - Assert.NotNull(historyNode); + Assert.IsNotNull(historyNode); var layerDiffsNode = result?["rootfs"]?["diff_ids"]; - Assert.NotNull(layerDiffsNode); + Assert.IsNotNull(layerDiffsNode); int nonEmptyHistoryNodes = historyNode.AsArray() .Count(h => h?.AsObject()["empty_layer"]?.GetValue() is null or false); int layerCount = layerDiffsNode.AsArray().Count; - Assert.Equal(nonEmptyHistoryNodes, layerCount); + Assert.AreEqual(nonEmptyHistoryNodes, layerCount); } - [Fact] + [TestMethod] public void CanSetUserFromAppUIDEnvVarFromBaseImage() { var expectedUid = "12345"; @@ -423,12 +433,12 @@ public void CanSetUserFromAppUIDEnvVarFromBaseImage() var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var assignedUid = result["config"]?["User"]?.GetValue(); - Assert.Equal(assignedUid, expectedUid); + Assert.AreEqual(expectedUid, assignedUid); } - [Fact] + [TestMethod] public void CanSetUserFromAppUIDEnvVarFromUser() { var expectedUid = "12345"; @@ -465,15 +475,15 @@ public void CanSetUserFromAppUIDEnvVarFromUser() var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var assignedUser = result["config"]?["User"]?.GetValue(); - Assert.Equal(assignedUser, expectedUid); + Assert.AreEqual(expectedUid, assignedUser); } - [InlineData("ASPNETCORE_URLS", "https://*:12345;http://+:1234;http://localhost:123;http://1.2.3.4:12", 12345, 1234, 123, 12)] - [InlineData("ASPNETCORE_HTTP_PORTS", "999;666", 999, 666)] - [InlineData("ASPNETCORE_HTTPS_PORTS", "456;789", 456, 789)] - [Theory] + [DataRow("ASPNETCORE_URLS", "https://*:12345;http://+:1234;http://localhost:123;http://1.2.3.4:12", 12345, 1234, 123, 12)] + [DataRow("ASPNETCORE_HTTP_PORTS", "999;666", 999, 666)] + [DataRow("ASPNETCORE_HTTPS_PORTS", "456;789", 456, 789)] + [TestMethod] public void CanSetPortFromEnvVarFromBaseImage(string envVar, string envValue, params int[] expectedPorts) { var builder = FromBaseImageConfig($$""" @@ -509,16 +519,16 @@ public void CanSetPortFromEnvVarFromBaseImage(string envVar, string envValue, pa var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); - Assert.Equal(assignedPorts, expectedPorts); + Assert.AreSequenceEqual(expectedPorts, assignedPorts); } - [InlineData("ASPNETCORE_URLS", "https://*:12345;http://+:1234;http://localhost:123;http://1.2.3.4:12", 12345, 1234, 123, 12)] - [InlineData("ASPNETCORE_HTTP_PORTS", "999;666", 999, 666)] - [InlineData("ASPNETCORE_HTTPS_PORTS", "456;789", 456, 789)] - [Theory] + [DataRow("ASPNETCORE_URLS", "https://*:12345;http://+:1234;http://localhost:123;http://1.2.3.4:12", 12345, 1234, 123, 12)] + [DataRow("ASPNETCORE_HTTP_PORTS", "999;666", 999, 666)] + [DataRow("ASPNETCORE_HTTPS_PORTS", "456;789", 456, 789)] + [TestMethod] public void CanSetPortFromEnvVarFromUser(string envVar, string envValue, params int[] expectedPorts) { var builder = FromBaseImageConfig($$""" @@ -555,14 +565,14 @@ public void CanSetPortFromEnvVarFromUser(string envVar, string envValue, params var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); - Assert.Equal(assignedPorts, expectedPorts); + Assert.AreSequenceEqual(expectedPorts, assignedPorts); } - [Fact] + [TestMethod] public void CanSetContainerUserAndOverrideAppUID() { var userId = "1646"; @@ -598,10 +608,10 @@ public void CanSetContainerUserAndOverrideAppUID() baseConfigBuilder.SetUser(userId); var config = JsonNode.Parse(baseConfigBuilder.Build().Config); - config!["config"]?["User"]?.GetValue().Should().Be(expected: userId, because: "The precedence of SetUser should override inferred user ids"); + Assert.AreEqual(userId, config!["config"]?["User"]?.GetValue()); } - [Fact] + [TestMethod] public void WhenMultipleUrlSourcesAreSetOnlyAspnetcoreUrlsIsUsed() { int[] expected = [12345]; @@ -638,13 +648,13 @@ public void WhenMultipleUrlSourcesAreSetOnlyAspnetcoreUrlsIsUsed() builder.AddEnvironmentVariable(ImageBuilder.EnvironmentVariables.ASPNETCORE_HTTPS_PORTS, "456"); var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var portsObject = result["config"]?["ExposedPorts"]?.AsObject(); var assignedPorts = portsObject?.AsEnumerable().Select(portString => int.Parse(portString.Key.Split('/')[0])).ToArray(); - Assert.Equal(expected, assignedPorts); + Assert.AreSequenceEqual(expected, assignedPorts); } - [Fact] + [TestMethod] public void CanSetBaseImageDigestLabel() { var builder = FromBaseImageConfig($$""" @@ -679,10 +689,10 @@ public void CanSetBaseImageDigestLabel() builder.AddBaseImageDigestLabel(); var builtImage = builder.Build(); JsonNode? result = JsonNode.Parse(builtImage.Config); - Assert.NotNull(result); + Assert.IsNotNull(result); var labels = result["config"]?["Labels"]?.AsObject(); var digest = labels?.AsEnumerable().First(label => label.Key == "org.opencontainers.image.base.digest").Value!; - digest.GetValue().Should().Be(StaticKnownDigestValue); + Assert.AreEqual(StaticKnownDigestValue, digest.GetValue()); } private ImageBuilder FromBaseImageConfig(string baseImageConfig, [CallerMemberName] string testName = "") diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs index 25d3cafe2268..da884160594a 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageConfigTests.cs @@ -5,6 +5,7 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class ImageConfigTests { private const string SampleImageConfig = """ @@ -43,15 +44,15 @@ public class ImageConfigTests } """; - [InlineData("User")] - [InlineData("Volumes")] - [InlineData("StopSignal")] - [Theory] + [DataRow("User")] + [DataRow("Volumes")] + [DataRow("StopSignal")] + [TestMethod] public void PassesThroughPropertyEvenThoughPropertyIsntExplicitlyHandled(string property) { ImageConfig c = new(SampleImageConfig); JsonNode after = JsonNode.Parse(c.BuildConfig())!; JsonNode? prop = after["config"]?[property]; - Assert.NotNull(prop); + Assert.IsNotNull(prop); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs index 1acc846dcdcd..9e45b8b91b19 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageIndexGeneratorTests.cs @@ -5,25 +5,26 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class ImageIndexGeneratorTests { - [Fact] + [TestMethod] public void ImagesCannotBeEmpty() { BuiltImage[] images = Array.Empty(); - var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images)); - Assert.Equal(Strings.ImagesEmpty, ex.Message); + var ex = Assert.ThrowsExactly(() => ImageIndexGenerator.GenerateImageIndex(images)); + Assert.AreEqual(Strings.ImagesEmpty, ex.Message); } - [Fact] + [TestMethod] public void ImagesCannotBeEmpty_SpecifiedMediaType() { BuiltImage[] images = Array.Empty(); - var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images, "manifestMediaType", "imageIndexMediaType")); - Assert.Equal(Strings.ImagesEmpty, ex.Message); + var ex = Assert.ThrowsExactly(() => ImageIndexGenerator.GenerateImageIndex(images, "manifestMediaType", "imageIndexMediaType")); + Assert.AreEqual(Strings.ImagesEmpty, ex.Message); } - [Fact] + [TestMethod] public void UnsupportedMediaTypeThrows() { BuiltImage[] images = @@ -37,13 +38,13 @@ public void UnsupportedMediaTypeThrows() } ]; - var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images)); - Assert.Equal(string.Format(Strings.UnsupportedMediaType, "unsupported"), ex.Message); + var ex = Assert.ThrowsExactly(() => ImageIndexGenerator.GenerateImageIndex(images)); + Assert.AreEqual(string.Format(Strings.UnsupportedMediaType, "unsupported"), ex.Message); } - [Theory] - [InlineData(SchemaTypes.DockerManifestV2)] - [InlineData(SchemaTypes.OciManifestV1)] + [TestMethod] + [DataRow(SchemaTypes.DockerManifestV2)] + [DataRow(SchemaTypes.OciManifestV1)] public void ImagesWithMixedMediaTypes(string supportedMediaType) { BuiltImage[] images = @@ -64,11 +65,11 @@ public void ImagesWithMixedMediaTypes(string supportedMediaType) } ]; - var ex = Assert.Throws(() => ImageIndexGenerator.GenerateImageIndex(images)); - Assert.Equal(Strings.MixedMediaTypes, ex.Message); + var ex = Assert.ThrowsExactly(() => ImageIndexGenerator.GenerateImageIndex(images)); + Assert.AreEqual(Strings.MixedMediaTypes, ex.Message); } - [Fact] + [TestMethod] public void GenerateDockerManifestList() { BuiltImage[] images = @@ -94,11 +95,11 @@ public void GenerateDockerManifestList() ]; var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images); - Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.list.v2+json\",\"manifests\":[{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); - Assert.Equal(SchemaTypes.DockerManifestListV2, mediaType); + Assert.AreEqual("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.list.v2+json\",\"manifests\":[{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); + Assert.AreEqual(SchemaTypes.DockerManifestListV2, mediaType); } - [Fact] + [TestMethod] public void GenerateOciImageIndex() { BuiltImage[] images = @@ -124,14 +125,14 @@ public void GenerateOciImageIndex() ]; var (imageIndex, mediaType) = ImageIndexGenerator.GenerateImageIndex(images); - Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); - Assert.Equal(SchemaTypes.OciImageIndexV1, mediaType); + Assert.AreEqual("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest1\",\"platform\":{\"architecture\":\"arch1\",\"os\":\"os1\"}},{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"size\":3,\"digest\":\"sha256:digest2\",\"platform\":{\"architecture\":\"arch2\",\"os\":\"os2\"}}]}", imageIndex); + Assert.AreEqual(SchemaTypes.OciImageIndexV1, mediaType); } - [Fact] + [TestMethod] public void GenerateImageIndexWithAnnotations() { string imageIndex = ImageIndexGenerator.GenerateImageIndexWithAnnotations("mediaType", "sha256:digest", 3, "repository", ["1.0", "2.0"]); - Assert.Equal("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:1.0\",\"org.opencontainers.image.ref.name\":\"1.0\"}},{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:2.0\",\"org.opencontainers.image.ref.name\":\"2.0\"}}]}", imageIndex); + Assert.AreEqual("{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:1.0\",\"org.opencontainers.image.ref.name\":\"1.0\"}},{\"mediaType\":\"mediaType\",\"size\":3,\"digest\":\"sha256:digest\",\"platform\":{},\"annotations\":{\"io.containerd.image.name\":\"docker.io/library/repository:2.0\",\"org.opencontainers.image.ref.name\":\"2.0\"}}]}", imageIndex); } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs b/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs new file mode 100644 index 000000000000..dc57e45b955d --- /dev/null +++ b/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.NET.Build.Containers.UnitTests; + +public sealed class InMemoryLoggerProvider(List<(LogLevel, string)> messagesCollection) : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new InMemoryLogger(messagesCollection); + + public void Dispose() + { + } + + private sealed class InMemoryLogger(List<(LogLevel, string)> messagesCollection) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => messagesCollection.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj index 8b77b42790a9..680bde78c1ef 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj +++ b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj @@ -1,18 +1,21 @@ - + - $(SdkTargetFramework) + + true + + $(SdkTargetFramework) enable - false - true MicrosoftShared - Exe - + + + diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs index 5f20f4c82058..29d8bfbc6fc9 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Net; @@ -14,15 +14,17 @@ namespace Microsoft.NET.Build.Containers.UnitTests; +[TestClass] public class RegistryTests : IDisposable { - private ITestOutputHelper _testOutput; - private readonly TestLoggerFactory _loggerFactory; + private TestLoggerFactory _loggerFactory = default!; - public RegistryTests(ITestOutputHelper testOutput) + public TestContext TestContext { get; set; } = default!; + + [TestInitialize] + public void Initialize() { - _testOutput = testOutput; - _loggerFactory = new TestLoggerFactory(testOutput); + _loggerFactory = new TestLoggerFactory(TestContext); } public void Dispose() @@ -30,27 +32,27 @@ public void Dispose() _loggerFactory.Dispose(); } - [InlineData("us-south1-docker.pkg.dev", true)] - [InlineData("us.gcr.io", false)] - [Theory] + [DataRow("us-south1-docker.pkg.dev", true)] + [DataRow("us.gcr.io", false)] + [TestMethod] public void CheckIfGoogleArtifactRegistry(string registryName, bool isECR) { ILogger logger = _loggerFactory.CreateLogger(nameof(CheckIfGoogleArtifactRegistry)); Registry registry = new(registryName, logger, RegistryMode.Push); - Assert.Equal(isECR, registry.IsGoogleArtifactRegistry); + Assert.AreEqual(isECR, registry.IsGoogleArtifactRegistry); } - [Fact] + [TestMethod] public void DockerIoAlias() { ILogger logger = _loggerFactory.CreateLogger(nameof(DockerIoAlias)); Registry registry = new("docker.io", logger, RegistryMode.Push); - Assert.True(registry.IsDockerHub); - Assert.Equal("docker.io", registry.RegistryName); - Assert.Equal("registry-1.docker.io", registry.BaseUri.Host); + Assert.IsTrue(registry.IsDockerHub); + Assert.AreEqual("docker.io", registry.RegistryName); + Assert.AreEqual("registry-1.docker.io", registry.BaseUri.Host); } - [Fact] + [TestMethod] public async Task RegistriesThatProvideNoUploadSizeAttemptFullUpload() { ILogger logger = _loggerFactory.CreateLogger(nameof(RegistriesThatProvideNoUploadSizeAttemptFullUpload)); @@ -75,7 +77,7 @@ public async Task RegistriesThatProvideNoUploadSizeAttemptFullUpload() api.Verify(api => api.Blob.Upload.UploadAtomicallyAsync(uploadPath, It.IsAny(), It.IsAny()), Times.Once()); } - [Fact] + [TestMethod] public async Task RegistriesThatProvideUploadSizePrefersFullUploadWhenChunkSizeIsLowerThanContentLength() { ILogger logger = _loggerFactory.CreateLogger(nameof(RegistriesThatProvideUploadSizePrefersFullUploadWhenChunkSizeIsLowerThanContentLength)); @@ -109,7 +111,7 @@ public async Task RegistriesThatProvideUploadSizePrefersFullUploadWhenChunkSizeI api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Never); } - [Fact] + [TestMethod] public async Task RegistriesThatFailAtomicUploadFallbackToChunked() { ILogger logger = _loggerFactory.CreateLogger(nameof(RegistriesThatFailAtomicUploadFallbackToChunked)); @@ -144,7 +146,7 @@ public async Task RegistriesThatFailAtomicUploadFallbackToChunked() api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(contentLength / chunkSizeLessThanContentLength)); } - [Fact] + [TestMethod] public async Task ChunkedUploadCalculatesChunksCorrectly() { ILogger logger = _loggerFactory.CreateLogger(nameof(RegistriesThatFailAtomicUploadFallbackToChunked)); @@ -186,10 +188,10 @@ public async Task ChunkedUploadCalculatesChunksCorrectly() api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(10)); } - [Fact] + [TestMethod] public async Task PushAsync_Logging() { - using TestLoggerFactory loggerFactory = new(_testOutput); + using TestLoggerFactory loggerFactory = new(TestContext); List<(LogLevel, string)> loggedMessages = new(); loggerFactory.AddProvider(new InMemoryLoggerProvider(loggedMessages)); ILogger logger = loggerFactory.CreateLogger(nameof(PushAsync_Logging)); @@ -211,14 +213,14 @@ public async Task PushAsync_Logging() Registry registry = new("public.ecr.aws", logger, api.Object); await registry.PushLayerAsync(mockLayer.Object, repoName, CancellationToken.None); - Assert.NotEmpty(loggedMessages); - Assert.True(loggedMessages.All(m => m.Item1 == LogLevel.Trace)); + Assert.IsNotEmpty(loggedMessages); + Assert.IsTrue(loggedMessages.All(m => m.Item1 == LogLevel.Trace)); var messages = loggedMessages.Select(m => m.Item2).ToList(); - Assert.Contains(messages, m => m == "Started upload session for sha256:fafafafafafafafafafafafafafafafa"); - Assert.Contains(messages, m => m == "Finalized upload session for sha256:fafafafafafafafafafafafafafafafa"); + Assert.Contains("Started upload session for sha256:fafafafafafafafafafafafafafafafa", messages); + Assert.Contains("Finalized upload session for sha256:fafafafafafafafafafafafafafafafa", messages); } - [Fact] + [TestMethod] public async Task PushAsync_ForceChunkedUpload() { ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_ForceChunkedUpload)); @@ -260,7 +262,7 @@ public async Task PushAsync_ForceChunkedUpload() api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(10)); } - [Fact] + [TestMethod] public async Task CanParseRegistryDeclaredChunkSize_FromRange() { ILogger logger = _loggerFactory.CreateLogger(nameof(CanParseRegistryDeclaredChunkSize_FromRange)); @@ -279,10 +281,10 @@ public async Task CanParseRegistryDeclaredChunkSize_FromRange() DefaultBlobUploadOperations operations = new(new Uri("https://my-registy.com"), finalClient, logger); StartUploadInformation result = await operations.StartAsync(repoName, CancellationToken.None); - Assert.Equal("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); + Assert.AreEqual("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); } - [Fact] + [TestMethod] public async Task CanParseRegistryDeclaredChunkSize_FromOCIChunkMinLength() { ILogger logger = _loggerFactory.CreateLogger(nameof(CanParseRegistryDeclaredChunkSize_FromOCIChunkMinLength)); @@ -301,10 +303,10 @@ public async Task CanParseRegistryDeclaredChunkSize_FromOCIChunkMinLength() DefaultBlobUploadOperations operations = new(new Uri("https://my-registy.com"), finalClient, logger); StartUploadInformation result = await operations.StartAsync(repoName, CancellationToken.None); - Assert.Equal("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); + Assert.AreEqual("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); } - [Fact] + [TestMethod] public async Task CanParseRegistryDeclaredChunkSize_None() { ILogger logger = _loggerFactory.CreateLogger(nameof(CanParseRegistryDeclaredChunkSize_None)); @@ -322,10 +324,10 @@ public async Task CanParseRegistryDeclaredChunkSize_None() DefaultBlobUploadOperations operations = new(new Uri("https://my-registy.com"), finalClient, logger); StartUploadInformation result = await operations.StartAsync(repoName, CancellationToken.None); - Assert.Equal("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); + Assert.AreEqual("https://my-registy.com/v2/testRepo/blobs/uploads/", result.UploadUri.AbsoluteUri); } - [Fact] + [TestMethod] public async Task UploadBlobChunkedAsync_NormalFlow() { ILogger logger = _loggerFactory.CreateLogger(nameof(UploadBlobChunkedAsync_NormalFlow)); @@ -359,7 +361,7 @@ public async Task UploadBlobChunkedAsync_NormalFlow() api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(5)); } - [Fact] + [TestMethod] public async Task UploadBlobChunkedAsync_Failure() { ILogger logger = _loggerFactory.CreateLogger(nameof(UploadBlobChunkedAsync_NormalFlow)); @@ -389,19 +391,20 @@ public async Task UploadBlobChunkedAsync_Failure() }; Registry registry = new(registryUri, logger, api.Object, settings); - ApplicationException receivedException = await Assert.ThrowsAsync(() => registry.UploadBlobChunkedAsync(testStream, new StartUploadInformation(absoluteUploadUri), CancellationToken.None)); + ApplicationException receivedException = await Assert.ThrowsExactlyAsync(() => registry.UploadBlobChunkedAsync(testStream, new StartUploadInformation(absoluteUploadUri), CancellationToken.None)); - Assert.Equal(preparedException, receivedException); + Assert.AreEqual(preparedException, receivedException); api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(1)); } - [Theory(Skip = "https://github.com/dotnet/sdk/issues/42820")] - [InlineData(true, true, true)] - [InlineData(false, true, true)] - [InlineData(true, false, true)] - [InlineData(false, false, true)] - [InlineData(false, false, false)] + [TestMethod] + [Ignore("https://github.com/dotnet/sdk/issues/42820")] + [DataRow(true, true, true)] + [DataRow(false, true, true)] + [DataRow(true, false, true)] + [DataRow(false, false, true)] + [DataRow(false, false, false)] public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, bool httpServerCloseAbortive) { ILogger logger = _loggerFactory.CreateLogger(nameof(InsecureRegistry)); @@ -457,7 +460,7 @@ public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, catch { } } - }, TestContext.Current.CancellationToken); + }, TestContext.CancellationToken); RegistrySettings settings = new() { @@ -472,12 +475,12 @@ public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, { // Falls back to http (when serverIsHttps is false) or ignores https certificate errors (when serverIsHttps is true). // Results in throwing: CONTAINER2003: The manifest for dotnet/runtime:latest from registry hwas an unknown type. - await Assert.ThrowsAsync(() => getManifest); + await Assert.ThrowsExactlyAsync(() => getManifest); } else { // Does not fall back and throws HttpRequestException with SecureConnectionError. - Exception? exception = await Assert.ThrowsAnyAsync(() => getManifest); + Exception? exception = await Assert.ThrowsAsync(() => getManifest); try { // The AuthHandshakeMessageHandler may reach its retry limit and throw an ApplicationException. @@ -485,15 +488,14 @@ public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, { // Find the exception for the first failed attempt. exception = (exception.InnerException as AggregateException)?.InnerExceptions.FirstOrDefault(); - Assert.NotNull(exception); + Assert.IsNotNull(exception); } - Assert.IsType(exception); - HttpRequestException requestException = (HttpRequestException)exception; - Assert.Equal(HttpRequestError.SecureConnectionError, requestException.HttpRequestError); + HttpRequestException requestException = Assert.IsExactInstanceOfType(exception); + Assert.AreEqual(HttpRequestError.SecureConnectionError, requestException.HttpRequestError); // The FallbackToHttpMessageHandler should fall back (if this registry was configured as insecure). - Assert.True(FallbackToHttpMessageHandler.ShouldAttemptFallbackToHttp(requestException)); + Assert.IsTrue(FallbackToHttpMessageHandler.ShouldAttemptFallbackToHttp(requestException)); } catch { @@ -524,15 +526,15 @@ public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, } } - [InlineData("localhost", null, true)] - [InlineData("localhost:5000", null, true)] - [InlineData("public.ecr.aws", null, false)] - [InlineData("public.ecr.aws", "public.ecr.aws", true)] - [InlineData("public.ecr.aws", "Public.ecr.aws", true)] // ignore case - [InlineData("public.ecr.aws", "public.ecr.aws;docker.io", true)] // multiple registries - [InlineData("public.ecr.aws", ";public.ecr.aws ; docker.io ", true)] // ignore whitespace - [InlineData("public.ecr.aws", "public.ecr.aws2;docker.io ", false)] // full name match - [Theory] + [DataRow("localhost", null, true)] + [DataRow("localhost:5000", null, true)] + [DataRow("public.ecr.aws", null, false)] + [DataRow("public.ecr.aws", "public.ecr.aws", true)] + [DataRow("public.ecr.aws", "Public.ecr.aws", true)] // ignore case + [DataRow("public.ecr.aws", "public.ecr.aws;docker.io", true)] // multiple registries + [DataRow("public.ecr.aws", ";public.ecr.aws ; docker.io ", true)] // ignore whitespace + [DataRow("public.ecr.aws", "public.ecr.aws2;docker.io ", false)] // full name match + [TestMethod] public void IsRegistryInsecure(string registryName, string? insecureRegistriesEnvvar, bool expectedInsecure) { var environment = new Dictionary(); @@ -543,10 +545,10 @@ public void IsRegistryInsecure(string registryName, string? insecureRegistriesEn var registrySettings = new RegistrySettings(registryName, new MockEnvironmentProvider(environment)); - Assert.Equal(expectedInsecure, registrySettings.IsInsecure); + Assert.AreEqual(expectedInsecure, registrySettings.IsInsecure); } - [Fact] + [TestMethod] public async Task DownloadBlobAsync_RetriesOnFailure() { // Arrange @@ -572,8 +574,8 @@ public async Task DownloadBlobAsync_RetriesOnFailure() result = await registry.DownloadBlobAsync(repoName, descriptor, cancellationToken); // Assert - Assert.NotNull(result); - Assert.True(File.Exists(result)); // Ensure the file was successfully downloaded + Assert.IsNotNull(result); + Assert.IsTrue(File.Exists(result)); // Ensure the file was successfully downloaded mockRegistryAPI.Verify(api => api.Blob.GetStreamAsync(repoName, descriptor.Digest, cancellationToken), Times.Exactly(3)); // Verify retries } finally @@ -586,7 +588,7 @@ public async Task DownloadBlobAsync_RetriesOnFailure() } } - [Fact] + [TestMethod] public async Task DownloadBlobAsync_ThrowsAfterMaxRetries() { // Arrange @@ -609,7 +611,7 @@ public async Task DownloadBlobAsync_ThrowsAfterMaxRetries() Registry registry = new(repoName, logger, mockRegistryAPI.Object, null, () => TimeSpan.Zero); // Act & Assert - await Assert.ThrowsAsync(async () => + await Assert.ThrowsExactlyAsync(async () => { await registry.DownloadBlobAsync(repoName, descriptor, cancellationToken); }); diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/Resources/ResourceTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/Resources/ResourceTests.cs index 16181d50edd6..7322f5d5f67d 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/Resources/ResourceTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/Resources/ResourceTests.cs @@ -8,25 +8,26 @@ namespace Microsoft.NET.Build.Containers.UnitTests.Resources { + [TestClass] public class ResourceTests { - [Fact] + [TestMethod] public void GetString_ReturnsValueFromResources() { - Assert.Equal("CONTAINER0000: Value for unit test {0}", Resource.GetString(nameof(Strings._Test))); + Assert.AreEqual("CONTAINER0000: Value for unit test {0}", Resource.GetString(nameof(Strings._Test))); } - [Fact] + [TestMethod] public void FormatString_ReturnsValueFromResources() { - Assert.Equal("CONTAINER0000: Value for unit test 1", Resource.FormatString(nameof(Strings._Test), 1)); + Assert.AreEqual("CONTAINER0000: Value for unit test 1", Resource.FormatString(nameof(Strings._Test), 1)); } - [Fact] + [TestMethod] public void EnsureErrorCodeUniqueness() { ResourceSet? resourceSet = Resource.Manager.GetResourceSet(CultureInfo.InvariantCulture, true, true); - Assert.NotNull(resourceSet); + Assert.IsNotNull(resourceSet); IEnumerable> groups = resourceSet .OfType() @@ -44,7 +45,10 @@ public void EnsureErrorCodeUniqueness() { string prefix = group.First().Key!.ToString()!.Split('_')[0]; - Assert.All(group, e => e.Key!.ToString()!.StartsWith(prefix, StringComparison.Ordinal)); + foreach (DictionaryEntry entry in group) + { + Assert.StartsWith(prefix, entry.Key!.ToString()!, StringComparison.Ordinal); + } } } } diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs b/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs new file mode 100644 index 000000000000..d15f8cffb091 --- /dev/null +++ b/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.NET.Build.Containers.UnitTests; + +public sealed class TestLoggerFactory : ILoggerFactory +{ + private readonly List _loggerProviders = new(); + private readonly List _factories = new(); + + public TestLoggerFactory(TestContext? testContext = null) + { + if (testContext is not null) + { + _loggerProviders.Add(new TestContextLoggerProvider(testContext)); + } + } + + public void Dispose() + { + while (_factories.Count > 0) + { + ILoggerFactory factory = _factories[0]; + _factories.RemoveAt(0); + factory.Dispose(); + } + } + + public ILogger CreateLogger(string categoryName) + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + + foreach (ILoggerProvider loggerProvider in _loggerProviders) + { + builder.AddProvider(loggerProvider); + } + + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss.fff] "; + options.IncludeScopes = true; + }); + }); + + _factories.Add(loggerFactory); + return loggerFactory.CreateLogger(categoryName); + } + + public ILogger CreateLogger() => CreateLogger("Test Host"); + + public void AddProvider(ILoggerProvider provider) => _loggerProviders.Add(provider); + + private sealed class TestContextLoggerProvider(TestContext testContext) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) => new TestContextLogger(testContext, categoryName); + + public void Dispose() + { + } + } + + private sealed class TestContextLogger(TestContext testContext, string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + testContext.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); + if (exception is not null) + { + testContext.WriteLine(exception.ToString()); + } + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} From ae897e707295c0268f9c4718291f99c40ce6afd8 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Fri, 12 Jun 2026 11:03:31 +0200 Subject: [PATCH 10/14] Decouple IntegrationTests from MSTest UnitTests project The Containers.UnitTests project was migrated to MSTest.Sdk in this PR, which brought a transitive 'global using Microsoft.VisualStudio.TestTools.UnitTesting;' into the still-xUnit IntegrationTests project via its ProjectReference, breaking every Assert call with CS0104 (ambiguous between MSTest and Xunit Assert types). - Remove the ProjectReference: IntegrationTests no longer used any types from the UnitTests assembly. - Add DockerAvailableFactAttribute and DockerAvailableTheoryAttribute locally in the IntegrationTests namespace (xUnit-flavored), restoring the API that the previous shared attributes provided. - Drop the now-unused 'using Microsoft.NET.Build.Containers.UnitTests;' from 5 test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CreateImageIndexTests.cs | 3 +- .../CreateNewImageTests.cs | 1 - .../DockerAvailableAttributes.cs | 84 +++++++++++++++++++ .../DockerRegistryTests.cs | 1 - .../EndToEndTests.cs | 3 +- ...T.Build.Containers.IntegrationTests.csproj | 1 - .../ParseContainerPropertiesTests.cs | 1 - 7 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 test/Microsoft.NET.Build.Containers.IntegrationTests/DockerAvailableAttributes.cs diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs index 2ceadd1e46b1..55ba6468ea36 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateImageIndexTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; @@ -6,7 +6,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Microsoft.NET.Build.Containers.IntegrationTests; -using Microsoft.NET.Build.Containers.UnitTests; using NuGet.Protocol; using Task = System.Threading.Tasks.Task; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs index 774ce35156c5..5293fce8e81c 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/CreateNewImageTests.cs @@ -6,7 +6,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Microsoft.NET.Build.Containers.IntegrationTests; -using Microsoft.NET.Build.Containers.UnitTests; namespace Microsoft.NET.Build.Containers.Tasks.IntegrationTests; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerAvailableAttributes.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerAvailableAttributes.cs new file mode 100644 index 000000000000..aabc4299099f --- /dev/null +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerAvailableAttributes.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Microsoft.NET.Build.Containers.IntegrationTests; + +// xUnit-flavoured Docker availability attributes for the IntegrationTests project. +// +// The companion Microsoft.NET.Build.Containers.UnitTests project was migrated to +// MSTest.Sdk and now exposes a `DockerUnavailableCondition : ConditionBaseAttribute` +// instead of the previous `DockerAvailableFactAttribute : FactAttribute` / +// `DockerAvailableTheoryAttribute : TheoryAttribute` pair. IntegrationTests is still +// an xUnit project, so it needs its own xUnit-compatible attributes. +// +// Implementation mirrors the previous UnitTests version: each invocation shells out to +// the docker CLI on first use and caches the result. Caching lives in DockerCliStatus +// (file-scoped class) so it does not pollute the rest of the namespace. + +public class DockerAvailableTheoryAttribute : TheoryAttribute +{ + public static string LocalRegistry => DockerCliStatus.LocalRegistry; + + public DockerAvailableTheoryAttribute( + bool skipPodman = false, + [CallerFilePath] string? sourceFilePath = null, + [CallerLineNumber] int sourceLineNumber = 0) + : base(sourceFilePath, sourceLineNumber) + { + if (!DockerCliStatus.IsAvailable) + { + base.Skip = "Skipping test because Docker is not available on this host."; + } + + if (skipPodman && DockerCliStatus.Command == DockerCli.PodmanCommand) + { + base.Skip = $"Skipping test with {DockerCliStatus.Command} cli."; + } + } +} + +public class DockerAvailableFactAttribute : FactAttribute +{ + public static string LocalRegistry => DockerCliStatus.LocalRegistry; + + public DockerAvailableFactAttribute( + bool skipPodman = false, + bool checkContainerdStoreAvailability = false, + [CallerFilePath] string? sourceFilePath = null, + [CallerLineNumber] int sourceLineNumber = 0) + : base(sourceFilePath, sourceLineNumber) + { + if (!DockerCliStatus.IsAvailable) + { + base.Skip = "Skipping test because Docker is not available on this host."; + } + else if (checkContainerdStoreAvailability && DockerCliStatus.Command != DockerCli.PodmanCommand && !DockerCli.IsContainerdStoreEnabledForDocker()) + { + base.Skip = "Skipping test because Docker daemon is not using containerd as the storage driver."; + } + else if (skipPodman && DockerCliStatus.Command == DockerCli.PodmanCommand) + { + base.Skip = $"Skipping test with {DockerCliStatus.Command} cli."; + } + } +} + +// tiny optimization - since there are many instances of these attributes we should only +// query the daemon status once. +static file class DockerCliStatus +{ + public static readonly bool IsAvailable; + public static readonly string? Command; + public static string LocalRegistry + => Command == DockerCli.PodmanCommand ? KnownLocalRegistryTypes.Podman + : KnownLocalRegistryTypes.Docker; + + static DockerCliStatus() + { + DockerCli cli = new(new Microsoft.NET.TestFramework.TestLoggerFactory()); + IsAvailable = cli.IsAvailable(); + Command = cli.GetCommand(); + } +} diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs index 24bfe1f73a89..9a0642013945 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.NET.Build.Containers.UnitTests; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.NET.Build.Containers.IntegrationTests; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs index 8a8678cae54e..e4e5a10649e1 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Formats.Tar; @@ -9,7 +9,6 @@ using Microsoft.DotNet.Cli.Utils; using Microsoft.NET.Build.Containers.LocalDaemons; using Microsoft.NET.Build.Containers.Resources; -using Microsoft.NET.Build.Containers.UnitTests; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.NET.Build.Containers.IntegrationTests; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/Microsoft.NET.Build.Containers.IntegrationTests.csproj b/test/Microsoft.NET.Build.Containers.IntegrationTests/Microsoft.NET.Build.Containers.IntegrationTests.csproj index 2b580a92681a..1917229db766 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/Microsoft.NET.Build.Containers.IntegrationTests.csproj +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/Microsoft.NET.Build.Containers.IntegrationTests.csproj @@ -25,7 +25,6 @@ - diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs index 29790ea7e203..d5355fd67b91 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.NET.Build.Containers.IntegrationTests; -using Microsoft.NET.Build.Containers.UnitTests; using static Microsoft.NET.Build.Containers.KnownStrings; using static Microsoft.NET.Build.Containers.KnownStrings.Properties; From 77af185255896fbf3fe870b9c8247d4adcffd5a1 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Fri, 12 Jun 2026 13:37:08 +0200 Subject: [PATCH 11/14] Use built-in $(UsingMSTestSdk) property instead of custom $(UseMSTestSdk) MSTest.Sdk already sets $(UsingMSTestSdk)=true in its Sdk.props before Directory.Build.props is evaluated, so the custom true opt-in property is redundant. This change: - Removes true from MSTest.Sdk csproj(s). - Renames $(UseMSTestSdk) -> $(UsingMSTestSdk) in test/Directory.Build.targets (the xUnit-defaults gating condition) and in xunit-runner/{XUnitPublish,XUnitRunner}.targets (Helix MTP dispatcher detection). --- .../Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj | 3 --- .../Microsoft.NET.Build.Containers.UnitTests.csproj | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj index 2ae0f2fe0244..0aec8ad4a1ee 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj @@ -1,9 +1,6 @@ - - true $(SdkTargetFramework) Microsoft.DotNet.Watch.Aspire.UnitTests diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj index 680bde78c1ef..7814b0deed36 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj +++ b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj @@ -1,9 +1,6 @@ - + - - true $(SdkTargetFramework) enable From b5ef12814c1f44b83e88544453300a50661bace5 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle <223556219+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:40:07 +0200 Subject: [PATCH 12/14] Use ctor injection of TestContext to make fields readonly Replaces [TestInitialize] with constructor injection of TestContext (MSTest 3.6+), allowing _loggerFactory to become readonly. Aligns with the writing-mstest-tests skill guidance: "Always initialize in the constructor" / "Use [TestInitialize] only for async initialization". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DockerDaemonTests.cs | 10 +++++----- .../ImageBuilderTests.cs | 10 +++++----- .../RegistryTests.cs | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs index cf4b32c4471f..96789f16b33d 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/DockerDaemonTests.cs @@ -6,14 +6,14 @@ namespace Microsoft.NET.Build.Containers.UnitTests; [TestClass] public class DockerDaemonTests : IDisposable { - private TestLoggerFactory _loggerFactory = default!; + private readonly TestLoggerFactory _loggerFactory; - public TestContext TestContext { get; set; } = default!; + public TestContext TestContext { get; } - [TestInitialize] - public void Initialize() + public DockerDaemonTests(TestContext testContext) { - _loggerFactory = new TestLoggerFactory(TestContext); + TestContext = testContext; + _loggerFactory = new TestLoggerFactory(testContext); } public void Dispose() diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs index 7c017b865141..849826edab2f 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/ImageBuilderTests.cs @@ -9,16 +9,16 @@ namespace Microsoft.NET.Build.Containers.UnitTests; [TestClass] public class ImageBuilderTests { - private TestLoggerFactory _loggerFactory = default!; + private readonly TestLoggerFactory _loggerFactory; private static readonly string StaticKnownDigestValue = "sha256:338c0b702da88157ba4bb706678e43346ece2e4397b888d59fb2d9f6113c8070"; - public TestContext TestContext { get; set; } = default!; + public TestContext TestContext { get; } - [TestInitialize] - public void Initialize() + public ImageBuilderTests(TestContext testContext) { - _loggerFactory = new TestLoggerFactory(TestContext); + TestContext = testContext; + _loggerFactory = new TestLoggerFactory(testContext); } [TestCleanup] diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs index 29d8bfbc6fc9..a13b4f27fd8e 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs @@ -17,14 +17,14 @@ namespace Microsoft.NET.Build.Containers.UnitTests; [TestClass] public class RegistryTests : IDisposable { - private TestLoggerFactory _loggerFactory = default!; + private readonly TestLoggerFactory _loggerFactory; - public TestContext TestContext { get; set; } = default!; + public TestContext TestContext { get; } - [TestInitialize] - public void Initialize() + public RegistryTests(TestContext testContext) { - _loggerFactory = new TestLoggerFactory(TestContext); + TestContext = testContext; + _loggerFactory = new TestLoggerFactory(testContext); } public void Dispose() From 1c3c44a64bd15f9786260171017c60e8ba70f687 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Fri, 12 Jun 2026 18:36:13 +0200 Subject: [PATCH 13/14] Use shared Microsoft.DotNet.Test.MSTest.Utilities for logger helpers Replace the local TestLoggerFactory.cs and InMemoryLoggerProvider.cs copies with a project reference to the shared MSTest utilities project introduced in the Aspire migration PR (#54722), keeping a single source of truth for these logging helpers. A global Using is added in the csproj so the existing consumer files don't need per-file using directives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InMemoryLoggerProvider.cs | 34 ------- ...soft.NET.Build.Containers.UnitTests.csproj | 5 + .../TestLoggerFactory.cs | 91 ------------------- 3 files changed, 5 insertions(+), 125 deletions(-) delete mode 100644 test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs delete mode 100644 test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs b/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs deleted file mode 100644 index dc57e45b955d..000000000000 --- a/test/Microsoft.NET.Build.Containers.UnitTests/InMemoryLoggerProvider.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.NET.Build.Containers.UnitTests; - -public sealed class InMemoryLoggerProvider(List<(LogLevel, string)> messagesCollection) : ILoggerProvider -{ - public ILogger CreateLogger(string categoryName) => new InMemoryLogger(messagesCollection); - - public void Dispose() - { - } - - private sealed class InMemoryLogger(List<(LogLevel, string)> messagesCollection) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - => messagesCollection.Add((logLevel, formatter(state, exception))); - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - - public void Dispose() - { - } - } -} diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj index 7814b0deed36..1bbaf68ac664 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj +++ b/test/Microsoft.NET.Build.Containers.UnitTests/Microsoft.NET.Build.Containers.UnitTests.csproj @@ -11,9 +11,14 @@ + + + + + diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs b/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs deleted file mode 100644 index d15f8cffb091..000000000000 --- a/test/Microsoft.NET.Build.Containers.UnitTests/TestLoggerFactory.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.NET.Build.Containers.UnitTests; - -public sealed class TestLoggerFactory : ILoggerFactory -{ - private readonly List _loggerProviders = new(); - private readonly List _factories = new(); - - public TestLoggerFactory(TestContext? testContext = null) - { - if (testContext is not null) - { - _loggerProviders.Add(new TestContextLoggerProvider(testContext)); - } - } - - public void Dispose() - { - while (_factories.Count > 0) - { - ILoggerFactory factory = _factories[0]; - _factories.RemoveAt(0); - factory.Dispose(); - } - } - - public ILogger CreateLogger(string categoryName) - { - ILoggerFactory loggerFactory = LoggerFactory.Create(builder => - { - builder.SetMinimumLevel(LogLevel.Trace); - - foreach (ILoggerProvider loggerProvider in _loggerProviders) - { - builder.AddProvider(loggerProvider); - } - - builder.AddSimpleConsole(options => - { - options.SingleLine = true; - options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss.fff] "; - options.IncludeScopes = true; - }); - }); - - _factories.Add(loggerFactory); - return loggerFactory.CreateLogger(categoryName); - } - - public ILogger CreateLogger() => CreateLogger("Test Host"); - - public void AddProvider(ILoggerProvider provider) => _loggerProviders.Add(provider); - - private sealed class TestContextLoggerProvider(TestContext testContext) : ILoggerProvider - { - public ILogger CreateLogger(string categoryName) => new TestContextLogger(testContext, categoryName); - - public void Dispose() - { - } - } - - private sealed class TestContextLogger(TestContext testContext, string categoryName) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - testContext.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); - if (exception is not null) - { - testContext.WriteLine(exception.ToString()); - } - } - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - - public void Dispose() - { - } - } -} From 1ef3028e638e064056d05cfb3117bd9387e4c94e Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Tue, 16 Jun 2026 05:58:32 +0200 Subject: [PATCH 14/14] Deploy Aspire launcher runtime closure for integration tests AspireLauncherIntegrationTests exec the Aspire launcher (Microsoft.DotNet.HotReload.Watch.Aspire) as a child process. ExcludeAssets=Runtime omitted the launcher's transitive runtime dependencies (Microsoft.CodeAnalysis*) from the test output, so the launched process crashed at startup with FileNotFoundException and the tests failed with Assert.NotNull. Removing ExcludeAssets=Runtime deploys the full closure next to the launcher. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/dotnet-watch.Tests/dotnet-watch.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 3b45b8eb3031..b31410e85e75 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -19,7 +19,7 @@ - +