diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index d2b8bad2d90b..91ae3f2a609e 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -1073,29 +1073,36 @@ async ValueTask BuildWithFrameworkAndDeviceSelectionAsync() } } - // Select device if needed: - if (needsDeviceSelection - && rootProject.Targets.ContainsKey(TargetNames.ComputeAvailableDevices)) + // Select device if needed. + // Check the TFM-specific inner project node for ComputeAvailableDevices, since workload + // targets (e.g., MAUI) may only be imported when TargetFramework is set. + if (needsDeviceSelection) { Debug.Assert(deviceSelector != null); + Debug.Assert(projectGraph != null); - var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken); - if (deviceInfo == null) + var projectNodeForDeviceCheck = projectGraph.TryGetProjectNode(rootProject.FullPath, targetFramework); + if (projectNodeForDeviceCheck != null + && projectNodeForDeviceCheck.ProjectInstance.Targets.ContainsKey(TargetNames.ComputeAvailableDevices)) { - return false; - } + var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken); + if (deviceInfo == null) + { + return false; + } - selectedDevice = deviceInfo.Id; - selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier; - _context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice); + selectedDevice = deviceInfo.Id; + selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier; + _context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice); - // If the device provides a RuntimeIdentifier, re-restore so the assets file - // includes the RID target. This mirrors the dotnet-run behavior. - if (!string.IsNullOrEmpty(selectedDeviceRuntimeIdentifier)) - { - if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo)) + // If the device provides a RuntimeIdentifier, re-restore so the assets file + // includes the RID target. This mirrors the dotnet-run behavior. + if (!string.IsNullOrEmpty(selectedDeviceRuntimeIdentifier)) { - return false; + if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo)) + { + return false; + } } } } diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets new file mode 100644 index 000000000000..0a7f0e716417 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Devices.targets @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj new file mode 100644 index 000000000000..c80e3579ce15 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/DotnetRunDevicesWorkload.csproj @@ -0,0 +1,44 @@ + + + + Exe + net9.0;$(CurrentTargetFramework) + false + + + + + + + + + + + $(IntermediateOutputPath)DeviceInfo.cs + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs new file mode 100644 index 000000000000..98662b384587 --- /dev/null +++ b/test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace DotNetRunDevicesWorkload +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Hello from multi-targeted app!"); + Console.WriteLine($"Target Framework: {AppContext.TargetFrameworkName}"); + Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); + + // DeviceInfo class is generated at build time when Device property is set + Console.WriteLine($"Device: {DeviceInfo.Device}"); + Console.WriteLine($"RuntimeIdentifier: {DeviceInfo.RuntimeIdentifier}"); + } + } +} diff --git a/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs b/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs index 091146408807..90725fd88672 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildParametersSelectionPromptTests.cs @@ -218,4 +218,29 @@ public void FormatDevice_IdOnly() var formatted = SpectreBuildParametersSelectionPrompt.FormatDevice(device); Assert.Equal("device-1", formatted); } + + [Fact] + public async Task SelectsFrameworkThenDevice() + { + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; + + // Push keys for TFM selection (search + enter) + console.Input.PushText("net9.0"); + console.Input.PushKey(ConsoleKey.Enter); + + // Push keys for device selection (search + enter) + console.Input.PushText("Pixel 7 Pro"); + console.Input.PushKey(ConsoleKey.Enter); + + var frameworks = new[] { "net7.0", "net8.0", "net9.0" }; + var devices = CreateTestDevices(); + var prompt = new SpectreBuildParametersSelectionPrompt(console); + + var selectedFramework = await prompt.SelectTargetFrameworkAsync(frameworks, CancellationToken.None); + Assert.Equal("net9.0", selectedFramework); + + var selectedDevice = await prompt.SelectDeviceAsync(devices, CancellationToken.None); + Assert.Equal("0A041FDD400327", selectedDevice.Id); + } } diff --git a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs index ab063c6e8cd0..61d565436d0f 100644 --- a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs @@ -110,6 +110,90 @@ public async Task SelectsDevice() await App.WaitUntilOutputContains("Device: test-device-1"); } + /// + /// Tests that the device selection prompt works after a TFM selection prompt. + /// This is the scenario reported as hanging on macOS: when PhysicalConsole's + /// KeyPressed channel is used for the first Spectre prompt, the second prompt + /// must also receive key events from the same channel. + /// + [Fact] + public async Task SelectsFrameworkThenDevice() + { + var testAsset = TestAssets.CopyTestAsset("DotnetRunDevices") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + + // Start watch WITHOUT --framework so both TFM and device prompts appear. + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + // First prompt: select target framework + await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt); + + foreach (var c in tfm) + { + App.SendKey(c); + } + App.SendKey('\r'); + + // Second prompt: select device (this is the one that hangs without the fix) + await App.WaitUntilOutputContains(Resources.SelectDevicePrompt); + + foreach (var c in "test-device-1") + { + App.SendKey(c); + } + App.SendKey('\r'); + + // The app should launch and print the selected device + await App.WaitUntilOutputContains("Device: test-device-1"); + } + + /// + /// Simulates the MAUI scenario where ComputeAvailableDevices is only available + /// for TFM-specific inner builds (imported conditionally by workload targets). + /// When dotnet-watch loads the project graph with null TFM, the outer project + /// doesn't have the target, so device selection falls through to the child + /// dotnet-run process. This causes a stdin race: dotnet-watch's Console.ReadKey() + /// loop steals all key presses from the child process's device prompt. + /// + [Fact] + public async Task SelectsFrameworkThenDevice_WorkloadConditionalTarget() + { + var testAsset = TestAssets.CopyTestAsset("DotnetRunDevicesWorkload") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + + // Start watch WITHOUT --framework so both TFM and device prompts appear. + App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin); + + // First prompt: select target framework + await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt); + + foreach (var c in tfm) + { + App.SendKey(c); + } + App.SendKey('\r'); + + // Second prompt: select device. + // With the bug, dotnet-watch doesn't see ComputeAvailableDevices in the outer + // project, skips device selection, and launches the child dotnet-run which shows + // its own device prompt. The child's stdin competes with dotnet-watch's + // Console.ReadKey() loop, causing the prompt to hang. + await App.WaitUntilOutputContains(Resources.SelectDevicePrompt); + + foreach (var c in "test-device-1") + { + App.SendKey(c); + } + App.SendKey('\r'); + + // The app should launch and print the selected device + await App.WaitUntilOutputContains("Device: test-device-1"); + } + [Fact] public async Task AutoSelectsSingleDevice() {