diff --git a/documentation/specs/dotnet-run-for-maui.md b/documentation/specs/dotnet-run-for-maui.md index ba6413a66845..4562f63c7c74 100644 --- a/documentation/specs/dotnet-run-for-maui.md +++ b/documentation/specs/dotnet-run-for-maui.md @@ -140,6 +140,37 @@ device selection: single target framework. If that framework provides `ComputeAvailableDevices`, the user may be prompted for a device. +* **Build per target framework** — the selected `$(Device)` and any + `$(RuntimeIdentifier)` supplied by `ComputeAvailableDevices` are passed + to the build for that target framework. + +* **Deploy per target framework** — after a successful build and before + computing the test application's run arguments, `dotnet test` calls + `DeployToDevice` when the target exists. + + * `DeployToDevice` receives the selected `$(Device)`, + `$(TargetFramework)`, and `$(RuntimeIdentifier)` as global + properties. + + * Deployment still runs with `--no-build`, because the user may select + a different device for an existing build. + + * If `DeployToDevice` does not exist, deployment is skipped. + + * If `DeployToDevice` fails, `dotnet test` exits with an error and does + not invoke `ComputeRunArguments` or start the test application. + +* **`ComputeRunArguments` per target framework** — after deployment, + `dotnet test` calls `ComputeRunArguments` with the same device, + target framework, and runtime identifier. The resulting + `$(RunCommand)` and `$(RunArguments)` are used to start the test + application. + +* **Solutions** — automatic device selection and deployment happen for + each test project and target framework in a solution. An explicit + `--device` is rejected with `--solution`; use `--project` because a + device identifier is project- and platform-specific. + * **`--list-devices`** works the same as with `dotnet run`. * **`-e` / `--environment`** — `dotnet test` already supports `-e` to @@ -271,17 +302,25 @@ invoking the target. ## Binary Logs for Device Selection -When using `-bl` with `dotnet run`, all MSBuild operations are logged to a single -binlog file: device selection, build, deploy, and run argument computation. +When using `-bl` with `dotnet run`, all in-process MSBuild operations are +logged to a single binlog file: device selection, deploy, and run argument +computation. File naming for `dotnet run` binlogs: * `-bl:filename.binlog` creates `filename-dotnet-run.binlog` * `-bl` creates `msbuild-dotnet-run.binlog` -Note: The build step may also create `msbuild.binlog` separately. Use -`--no-build` with `-bl` to only capture run-specific MSBuild -operations. +`dotnet test` uses the same behavior for its in-process operations, +including per-project and per-target-framework device selection, +deployment, and run argument computation: + +* `-bl:filename.binlog` creates `filename-dotnet-test.binlog` +* `-bl` creates `msbuild-dotnet-test.binlog` + +The out-of-process build step may also create `msbuild.binlog` +separately. Use `--no-build` with `-bl` to capture only the in-process +run or test operations. ## What about Launch Profiles? diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs index e7612e0a8712..6e3d54ae2e8c 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs @@ -9,9 +9,10 @@ namespace Microsoft.DotNet.Cli.Commands.Test; [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")] -internal sealed class MSBuildHandler(BuildOptions buildOptions) : ITestHandler +internal sealed class MSBuildHandler(BuildOptions buildOptions, FacadeLogger? logger) : ITestHandler { private readonly BuildOptions _buildOptions = buildOptions; + private readonly FacadeLogger? _logger = logger; private readonly ConcurrentBag _testApplications = []; @@ -26,8 +27,8 @@ public bool Initialize() } (IEnumerable projects, int buildExitCode) = isSolution ? - MSBuildUtility.GetProjectsFromSolution(projectOrSolutionFilePath, _buildOptions) : - MSBuildUtility.GetProjectsFromProject(projectOrSolutionFilePath, _buildOptions); + MSBuildUtility.GetProjectsFromSolution(projectOrSolutionFilePath, _buildOptions, _logger) : + MSBuildUtility.GetProjectsFromProject(projectOrSolutionFilePath, _buildOptions, _logger); LogProjectProperties(projects); diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs index 1041731ca0f2..bb41d7f9c837 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs @@ -21,15 +21,16 @@ namespace Microsoft.DotNet.Cli.Commands.Test; internal static class MSBuildUtility { - private const string dotnetTestVerb = "dotnet-test"; - // Related: https://github.com/dotnet/msbuild/pull/7992 // Related: https://github.com/dotnet/msbuild/issues/12711 [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ProjectShouldBuild")] static extern bool ProjectShouldBuild(SolutionFile solutionFile, string projectFile); [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")] - public static (IEnumerable Projects, int BuildExitCode) GetProjectsFromSolution(string solutionFilePath, BuildOptions buildOptions) + public static (IEnumerable Projects, int BuildExitCode) GetProjectsFromSolution( + string solutionFilePath, + BuildOptions buildOptions, + FacadeLogger? logger) { int buildExitCode = BuildOrRestoreProjectOrSolution(solutionFilePath, buildOptions); @@ -68,27 +69,30 @@ public static (IEnumerable p.Item1.IncludeInBuild) .Select(p => (p.AbsolutePath, (string?)p.Item1.ConfigurationName, (string?)p.Item1.PlatformName)); - FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. buildOptions.MSBuildArgs], dotnetTestVerb); - using var collection = new ProjectCollection(globalProperties, loggers: logger is null ? null : [logger], toolsetDefinitionLocations: ToolsetDefinitionLocations.Default); var evaluationContext = EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared); - var (projects, deviceBuildExitCode) = GetProjectsProperties(collection, evaluationContext, projectPaths, buildOptions); - logger?.ReallyShutdown(); + var (projects, deviceBuildExitCode) = GetProjectsProperties(collection, evaluationContext, projectPaths, buildOptions, logger); collection.UnloadAllProjects(); return (projects, deviceBuildExitCode != 0 ? deviceBuildExitCode : buildExitCode); } [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")] - public static (IEnumerable Projects, int BuildExitCode) GetProjectsFromProject(string projectFilePath, BuildOptions buildOptions) + public static (IEnumerable Projects, int BuildExitCode) GetProjectsFromProject( + string projectFilePath, + BuildOptions buildOptions, + FacadeLogger? logger) { // Pre-build device selection: evaluate the project to select devices BEFORE building, // so that device-provided RuntimeIdentifiers are included in the build. - var deviceSelection = SolutionAndProjectUtility.SelectDevicesBeforeBuild(projectFilePath, buildOptions); + var deviceSelection = SolutionAndProjectUtility.SelectDevicesBeforeBuild( + projectFilePath, + buildOptions, + logger: logger); if (deviceSelection is not null) { - return BuildPerTfmWithDevices(projectFilePath, buildOptions, deviceSelection); + return BuildPerTfmWithDevices(projectFilePath, buildOptions, deviceSelection, logger); } int buildExitCode = BuildOrRestoreProjectOrSolution(projectFilePath, buildOptions); @@ -98,14 +102,11 @@ public static (IEnumerable(), buildExitCode); } - FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. buildOptions.MSBuildArgs], dotnetTestVerb); - var msbuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(buildOptions.MSBuildArgs, CommonOptions.CreatePropertyOption(), CommonOptions.CreateRestorePropertyOption(), CommonOptions.CreateMSBuildTargetOption(), CommonOptions.CreateVerbosityOption(), CommonOptions.CreateNoLogoOption()); using var collection = new ProjectCollection(globalProperties: CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs), logger is null ? null : [logger], toolsetDefinitionLocations: ToolsetDefinitionLocations.Default); var evaluationContext = EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared); - IEnumerable projects = SolutionAndProjectUtility.GetProjectProperties(projectFilePath, collection, evaluationContext, buildOptions, configuration: null, platform: null); - logger?.ReallyShutdown(); + IEnumerable projects = SolutionAndProjectUtility.GetProjectProperties(projectFilePath, collection, evaluationContext, buildOptions, logger, configuration: null, platform: null); collection.UnloadAllProjects(); return (projects, buildExitCode); } @@ -119,6 +120,7 @@ private static (IEnumerable(), exitCode); } - FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. perTfmBuildOptions.MSBuildArgs], dotnetTestVerb); - var msbuildArgs = SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(perTfmBuildOptions.MSBuildArgs); using var collection = new ProjectCollection( @@ -174,8 +174,7 @@ private static (IEnumerable modules = SolutionAndProjectUtility.GetProjectProperties( - projectFilePath, collection, evaluationContext, perTfmBuildOptions, configuration, platform); - logger?.ReallyShutdown(); + projectFilePath, collection, evaluationContext, perTfmBuildOptions, logger, configuration, platform); allGroups.AddRange(modules); } @@ -369,7 +368,8 @@ private static (ConcurrentBag projects, - BuildOptions buildOptions) + BuildOptions buildOptions, + FacadeLogger? logger) { var allProjects = new ConcurrentBag(); var nonDeviceProjects = new List<(string ProjectFilePath, string? Configuration, string? Platform)>(); @@ -378,11 +378,22 @@ private static (ConcurrentBag(); Parallel.ForEach( nonDeviceProjects, // We don't use --max-parallel-test-modules here. @@ -407,13 +419,25 @@ private static (ConcurrentBag { - IEnumerable projectsMetadata = SolutionAndProjectUtility.GetProjectProperties(project.ProjectFilePath, projectCollection, evaluationContext, buildOptions, project.Configuration, project.Platform); - foreach (var projectMetadata in projectsMetadata) + try + { + IEnumerable projectsMetadata = SolutionAndProjectUtility.GetProjectProperties(project.ProjectFilePath, projectCollection, evaluationContext, buildOptions, logger, project.Configuration, project.Platform); + foreach (var projectMetadata in projectsMetadata) + { + allProjects.Add(projectMetadata); + } + } + catch (GracefulException ex) { - allProjects.Add(projectMetadata); + gracefulExceptions.Enqueue(ex); } }); + if (gracefulExceptions.TryDequeue(out GracefulException? gracefulException)) + { + throw gracefulException; + } + return (allProjects, 0); } } diff --git a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs index b673194cbe7d..51193f4c51b9 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs @@ -40,28 +40,37 @@ public int Run(ParseResult parseResult, bool isHelp) throw new GracefulException(CliCommandStrings.CmdDeviceOptionsRequireProject); } - // --list-devices: list available devices for the project and exit early. - // Never builds, deploys, or runs tests. - if (buildOptions.ListDevices) + FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. buildOptions.MSBuildArgs], "dotnet-test"); + ITestHandler testHandler; + try { - return HandleListDevices(buildOptions); - } + // --list-devices: list available devices for the project and exit early. + // Never builds, deploys, or runs tests. + if (buildOptions.ListDevices) + { + return HandleListDevices(buildOptions, logger); + } - // When --device is specified, force single target framework selection because - // a device is platform-specific and we need to know which TFM was intended. - if (!string.IsNullOrWhiteSpace(buildOptions.Device)) - { - buildOptions = HandleDeviceWithTargetFrameworkSelection(buildOptions); - } + // When --device is specified, force single target framework selection because + // a device is platform-specific and we need to know which TFM was intended. + if (!string.IsNullOrWhiteSpace(buildOptions.Device)) + { + buildOptions = HandleDeviceWithTargetFrameworkSelection(buildOptions, logger); + } - ITestHandler testHandler = buildOptions.PathOptions.TestModules is { } testModules - ? new TestModulesFilterHandler(testModules, parseResult) - : RuntimeFeature.IsDynamicCodeSupported ? new MSBuildHandler(buildOptions) - : throw new PlatformNotSupportedException("Dynamic code is not supported on this platform."); + testHandler = buildOptions.PathOptions.TestModules is { } testModules + ? new TestModulesFilterHandler(testModules, parseResult) + : RuntimeFeature.IsDynamicCodeSupported ? new MSBuildHandler(buildOptions, logger) + : throw new PlatformNotSupportedException("Dynamic code is not supported on this platform."); - if (!testHandler.Initialize()) + if (!testHandler.Initialize()) + { + return ExitCode.GenericFailure; + } + } + finally { - return ExitCode.GenericFailure; + logger?.ReallyShutdown(); } int degreeOfParallelism = GetDegreeOfParallelism(parseResult); @@ -167,7 +176,7 @@ private static int GetDegreeOfParallelism(ParseResult parseResult) /// Solutions are rejected because each project may have its own device list, so /// applying a single --device value across a solution is ambiguous. /// - private static BuildOptions HandleDeviceWithTargetFrameworkSelection(BuildOptions buildOptions) + private static BuildOptions HandleDeviceWithTargetFrameworkSelection(BuildOptions buildOptions, FacadeLogger? logger) { var msbuildArgs = SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs); @@ -191,7 +200,10 @@ private static BuildOptions HandleDeviceWithTargetFrameworkSelection(BuildOption if (!globalProperties.ContainsKey(ProjectProperties.TargetFramework)) { // Evaluate the project to get TargetFrameworks - using var collection = new ProjectCollection(globalProperties); + using var collection = new ProjectCollection( + globalProperties, + logger is null ? null : [logger], + ToolsetDefinitionLocations.Default); var projectInstance = ProjectInstance.FromFile(projectPath, new ProjectOptions { GlobalProperties = globalProperties, @@ -241,7 +253,7 @@ private static BuildOptions HandleDeviceWithTargetFrameworkSelection(BuildOption /// , and exits without /// building, deploying, or running tests. /// - private static int HandleListDevices(BuildOptions buildOptions) + private static int HandleListDevices(BuildOptions buildOptions, FacadeLogger? logger) { if (!ValidationUtility.ValidateBuildPathOptions(buildOptions.PathOptions, out var projectPath, out bool isSolution)) { @@ -258,7 +270,6 @@ private static int HandleListDevices(BuildOptions buildOptions) bool isInteractive = !Console.IsOutputRedirected && !new CIEnvironmentDetectorForTelemetry().IsCIEnvironment(); var standardArgs = SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs); - // Mirror the `dotnet run --list-devices` flow: a single RunCommandSelector // handles both target framework selection and device listing, with // InvalidateGlobalProperties between steps so the device list is computed @@ -268,7 +279,8 @@ private static int HandleListDevices(BuildOptions buildOptions) isInteractive, standardArgs, ImmutableDictionary.Empty, - commandName: "dotnet test"); + commandName: "dotnet test", + logger); // Step 1: Prompt for TargetFramework if the project is multi-targeted and -f wasn't provided. if (!selector.TrySelectTargetFramework(out string? selectedFramework)) diff --git a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs index 3dbe46c66b0f..040d060bda53 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs @@ -239,6 +239,7 @@ public static IEnumerable(); innerModules.Add(module); @@ -321,7 +322,8 @@ public static IEnumerable(); foreach (var framework in frameworks) { - var (device, rid) = SelectDeviceForTfm(projectFilePath, buildOptions, framework, isInteractive); + var (device, rid) = SelectDeviceForTfm(projectFilePath, buildOptions, framework, isInteractive, logger); devicesByTfm[framework] = (device, rid); } @@ -408,7 +412,8 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm( string projectFilePath, BuildOptions buildOptions, string? tfm, - bool isInteractive) + bool isInteractive, + FacadeLogger? logger) { var msbuildArgsToAppend = buildOptions.MSBuildArgs; if (!string.IsNullOrEmpty(tfm)) @@ -423,7 +428,8 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm( isInteractive, msbuildArgs, ImmutableDictionary.Empty, - commandName: "dotnet test"); + commandName: "dotnet test", + logger); lock (s_buildLock) { @@ -443,7 +449,10 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm( } [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")] - private static TestModule? GetModuleFromProject(ProjectInstance project, BuildOptions buildOptions) + private static TestModule? GetModuleFromProject( + ProjectInstance project, + BuildOptions buildOptions, + FacadeLogger? logger) { _ = bool.TryParse(project.GetPropertyValue(ProjectProperties.IsTestProject), out bool isTestProject); _ = bool.TryParse(project.GetPropertyValue(ProjectProperties.IsTestingPlatformApplication), out bool isTestingPlatformApplication); @@ -461,7 +470,7 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm( RunProperties runProperties; if (isTestingPlatformApplication) { - runProperties = GetRunProperties(project); + runProperties = DeployAndGetRunProperties(project, logger); // dotnet run throws the same if RunCommand is null or empty. // In dotnet test, we are additionally checking that RunCommand is not dll. @@ -503,7 +512,7 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm( [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")] [UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")] - static RunProperties GetRunProperties(ProjectInstance project) + static RunProperties DeployAndGetRunProperties(ProjectInstance project, FacadeLogger? logger) { // Build API cannot be called in parallel, even if the projects are different. // Otherwise, BuildManager in MSBuild will fail: @@ -511,7 +520,14 @@ static RunProperties GetRunProperties(ProjectInstance project) // NOTE: BuildManager is singleton. lock (s_buildLock) { - if (!project.Build(s_computeRunArgumentsTarget, loggers: null)) + var loggers = logger is null ? null : new[] { logger }; + if (project.Targets.ContainsKey(Constants.DeployToDevice) && + !project.Build([Constants.DeployToDevice], loggers)) + { + throw new GracefulException(CliCommandStrings.RunCommandDeployFailed); + } + + if (!project.Build(s_computeRunArgumentsTarget, loggers)) { throw new GracefulException(CliCommandStrings.RunCommandEvaluationExceptionBuildFailed, s_computeRunArgumentsTarget[0]); } diff --git a/test/TestAssets/TestProjects/DotnetTestDevices/DotnetTestDevices.csproj b/test/TestAssets/TestProjects/DotnetTestDevices/DotnetTestDevices.csproj index af05cf1b7fa0..00abd4250628 100644 --- a/test/TestAssets/TestProjects/DotnetTestDevices/DotnetTestDevices.csproj +++ b/test/TestAssets/TestProjects/DotnetTestDevices/DotnetTestDevices.csproj @@ -25,7 +25,7 @@ - + @@ -38,4 +38,13 @@ + + + + + + diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestSelectsDevice.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestSelectsDevice.cs index 6e5c72a3a006..c18aa4d60aa3 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestSelectsDevice.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestSelectsDevice.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Build.Logging.StructuredLogger; using Microsoft.DotNet.Cli.Commands; +using System.Runtime.InteropServices; +using StructuredLoggerTarget = Microsoft.Build.Logging.StructuredLogger.Target; namespace Microsoft.DotNet.Cli.Test.Tests; @@ -15,6 +18,18 @@ public GivenDotnetTestSelectsDevice() { } + private static void AssertTargetInBinlog( + string binlogPath, + string targetName, + Action> assertion) + { + var build = BinaryLog.ReadBuild(binlogPath); + var targets = build.FindChildrenRecursive( + target => target.Name == targetName); + + assertion(targets); + } + [TestMethod] public void ItFailsInNonInteractiveMode_WhenMultipleDevicesAvailableAndNoneSpecified() { @@ -72,11 +87,13 @@ public void ItPromptsForTargetFrameworkWhenDeviceIsSpecifiedWithoutFramework_InN var result = new DotnetTestCommand(Log, disableNewOutput: false) .WithWorkingDirectory(testInstance.Path) .WithEnvironmentVariable("DOTNET_CLI_UI_LANGUAGE", "en-US") - .Execute("--device", "test-device-1"); + .Execute("--device", "test-device-1", "-bl"); // Should fail because non-interactive mode can't prompt for TF result.Should().Fail() .And.HaveStdErrContaining(string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyFramework, "--framework")); + File.Exists(Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog")) + .Should().BeTrue("target framework selection should be captured in the test binlog"); } [TestMethod] @@ -155,9 +172,16 @@ public void ItRunsDeviceProjectsInSolution() var result = new DotnetTestCommand(Log, disableNewOutput: false) .WithWorkingDirectory(testInstance.Path) - .Execute("--solution", "TestSolution.slnx", "-p:SingleDevice=true"); + .Execute("--solution", "TestSolution.slnx", "-p:SingleDevice=true", "-bl"); result.Should().Pass(); + + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + File.Exists(binlogPath).Should().BeTrue("the test binlog should be created"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => targets.Should().HaveCount(4, "both target frameworks in both projects should be deployed")); } [TestMethod] @@ -203,13 +227,20 @@ public void ItListsDevicesAndExits() var result = new DotnetTestCommand(Log, disableNewOutput: false) .WithWorkingDirectory(testInstance.Path) - .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--list-devices"); + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--list-devices", "-bl"); result.Should().Pass(); result.StdOut.Should().Contain("test-device-1"); result.StdOut.Should().Contain("test-device-2"); // Friendly example using "dotnet test --device ..." rather than "dotnet run --device ..." result.StdOut.Should().Contain("dotnet test --device"); + + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + File.Exists(binlogPath).Should().BeTrue("device listing should be captured in the test binlog"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => targets.Should().BeEmpty("--list-devices must exit before deployment")); } [TestMethod] @@ -391,4 +422,197 @@ public void ItErrorsWhenListDevicesIsCombinedWithTestModules() result.Should().Fail() .And.HaveStdErrContaining(CliCommandStrings.CmdDeviceOptionsRequireProject); } + + [TestMethod] + public void ItCallsDeployToDeviceTargetWhenDeviceIsSpecified() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "ExplicitDeploy") + .WithSource(); + string deviceId = "test-device-1"; + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--device", deviceId, "-bl"); + + result.Should().Pass(); + File.Exists(binlogPath).Should().BeTrue("the test binlog should be created"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => + { + targets.Should().ContainSingle("the selected test target framework should be deployed once"); + var deployMessage = targets.Single().FindChildrenRecursive() + .Single(message => message.Text.Contains("DeployToDevice: Deployed")); + deployMessage.Text.Should().Contain(deviceId, "the Device property should be passed to DeployToDevice"); + }); + } + + [TestMethod] + public void ItCallsDeployToDeviceTargetEvenWithNoBuild() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "NoBuildDeploy") + .WithSource(); + string deviceId = "test-device-1"; + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + + new DotnetCommand(Log, "build") + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, $"-p:Device={deviceId}") + .Should().Pass(); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--device", deviceId, "--no-build", "-bl"); + + result.Should().Pass(); + File.Exists(binlogPath).Should().BeTrue("the test binlog should be created"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => targets.Should().ContainSingle("deployment must run even when the build is skipped")); + } + + [TestMethod] + public void ItCallsDeployToDeviceTargetWhenDeviceIsAutoSelected() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "AutoDeploy") + .WithSource(); + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "-p:SingleDevice=true", "-bl"); + + result.Should().Pass(); + File.Exists(binlogPath).Should().BeTrue("the test binlog should be created"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => + { + targets.Should().ContainSingle("the selected test target framework should be deployed once"); + var deployMessage = targets.Single().FindChildrenRecursive() + .Single(message => message.Text.Contains("DeployToDevice: Deployed")); + deployMessage.Text.Should().Contain("single-device", "the auto-selected Device should be deployed"); + deployMessage.Text.Should().Contain( + RuntimeInformation.RuntimeIdentifier, + "the RuntimeIdentifier supplied by the selected device should be deployed"); + }); + } + + [TestMethod] + public void ItDeploysBeforeComputingRunArguments() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "DeployOrder") + .WithSource(); + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute( + "--framework", + ToolsetInfo.CurrentTargetFramework, + "--device", + "test-device-1", + "-bl"); + + result.Should().Pass(); + File.Exists(Path.Combine( + testInstance.Path, + "obj", + TestingConstants.Debug, + ToolsetInfo.CurrentTargetFramework, + "dotnet-test-deploy.marker")).Should().BeTrue(); + + var build = BinaryLog.ReadBuild(binlogPath); + var deployTarget = build.FindChildrenRecursive( + target => target.Name == "DeployToDevice").Should().ContainSingle().Which; + var computeRunArgumentsTarget = build.FindChildrenRecursive( + target => target.Name == "ComputeRunArguments").Should().ContainSingle().Which; + deployTarget.EndTime.Should().BeOnOrBefore( + computeRunArgumentsTarget.StartTime, + "deployment must complete before run arguments are computed"); + } + + [TestMethod] + public void ItFailsWhenDeployToDeviceTargetFails() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "DeployFailure") + .WithSource(); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .WithEnvironmentVariable("DOTNET_CLI_UI_LANGUAGE", "en-US") + .Execute( + "--framework", + ToolsetInfo.CurrentTargetFramework, + "--device", + "test-device-1", + "-p:FailDeployToDevice=true"); + + result.Should().Fail() + .And.HaveStdErrContaining(CliCommandStrings.RunCommandDeployFailed); + } + + [TestMethod] + public void ItReportsDeployToDeviceFailureGracefullyForSolutionProject() + { + var testInstance = TestAssetsManager.CopyTestAsset("TestProjectWithTests", identifier: "SolutionDeployFailure") + .WithSource(); + string projectPath = Path.Combine(testInstance.Path, "TestProject.csproj"); + string projectContents = File.ReadAllText(projectPath); + File.WriteAllText( + projectPath, + projectContents.Replace( + "", + """ + + + + + """)); + File.WriteAllText( + Path.Combine(testInstance.Path, "TestSolution.slnx"), + """ + + + + """); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .WithEnvironmentVariable("DOTNET_CLI_UI_LANGUAGE", "en-US") + .Execute("--solution", "TestSolution.slnx"); + + result.Should().Fail() + .And.HaveStdErrContaining(CliCommandStrings.RunCommandDeployFailed) + .And.NotHaveStdErrContaining(nameof(AggregateException)); + } + + [TestMethod] + public void ItDeploysEveryTargetFramework() + { + var testInstance = TestAssetsManager.CopyTestAsset("DotnetTestDevices", identifier: "MultiTargetDeploy") + .WithSource(); + string binlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-test.binlog"); + + var result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("-p:SingleDevice=true", "-bl"); + + result.Should().Pass(); + File.Exists(binlogPath).Should().BeTrue("the test binlog should be created"); + AssertTargetInBinlog( + binlogPath, + "DeployToDevice", + targets => + { + targets.Should().HaveCount(2, "each target framework should be deployed"); + var messages = targets.SelectMany(target => target.FindChildrenRecursive()); + messages.Should().Contain(message => message.Text.Contains("net9.0")); + messages.Should().Contain(message => message.Text.Contains(ToolsetInfo.CurrentTargetFramework)); + }); + } }