From 9e408d86f09ea59dd6f2d5c740d181d11156ed82 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 9 Mar 2026 12:06:20 +0000 Subject: [PATCH 1/9] Add EmulatorRunner for emulator CLI operations Adds EmulatorRunner with StartAvd, ListAvdNamesAsync, and BootAndWaitAsync. Adds virtual shell methods to AdbRunner for testability. Adds ConfigureEnvironment to AndroidEnvironmentHelper. 212/213 tests pass (1 pre-existing JDK failure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootOptions.cs | 18 + .../Models/EmulatorBootResult.cs | 15 + .../Runners/AdbRunner.cs | 36 +- .../Runners/AndroidEnvironmentHelper.cs | 11 + .../Runners/EmulatorRunner.cs | 237 +++++++++++++ .../EmulatorRunnerTests.cs | 326 ++++++++++++++++++ 6 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs new file mode 100644 index 00000000..8765df77 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -0,0 +1,18 @@ +// 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 Xamarin.Android.Tools +{ + /// + /// Options for booting an Android emulator. + /// + public class EmulatorBootOptions + { + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public string? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs new file mode 100644 index 00000000..59281792 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Result of an emulator boot operation. + /// + public class EmulatorBootResult + { + public bool Success { get; set; } + public string? Serial { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 24a62cec..da2e9799 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -47,7 +47,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab /// Lists connected devices using 'adb devices -l'. /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. /// - public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + public virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -135,6 +135,40 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } + /// + /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// + public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + } + + /// + /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + } + + internal static string? FirstNonEmptyLine (string output) + { + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (trimmed.Length > 0) + return trimmed; + } + return null; + } + /// /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 51f6309d..3cf67207 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -37,4 +37,15 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP return env; } + + /// + /// Applies Android SDK environment variables directly to a . + /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner). + /// + internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath) + { + var env = GetEnvironmentVariables (sdkPath, jdkPath); + foreach (var kvp in env) + psi.Environment [kvp.Key] = kvp.Value; + } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs new file mode 100644 index 00000000..837108f5 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Emulator commands. +/// +public class EmulatorRunner +{ + readonly Func getSdkPath; + readonly Func? getJdkPath; + + public EmulatorRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public EmulatorRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + public string? EmulatorPath { + get { + var sdkPath = getSdkPath (); + if (string.IsNullOrEmpty (sdkPath)) + return null; + + var ext = OS.IsWindows ? ".exe" : ""; + var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + + return File.Exists (path) ? path : null; + } + } + + public bool IsAvailable => EmulatorPath is not null; + + string RequireEmulatorPath () + { + return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); + } + + void ConfigureEnvironment (ProcessStartInfo psi) + { + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + } + + public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + { + var emulatorPath = RequireEmulatorPath (); + + var args = new List { "-avd", avdName }; + if (coldBoot) + args.Add ("-no-snapshot-load"); + if (!string.IsNullOrEmpty (additionalArgs)) + args.Add (additionalArgs); + + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); + ConfigureEnvironment (psi); + + // Redirect stdout/stderr so the emulator process doesn't inherit the + // caller's pipes. Without this, parent processes (e.g. VS Code spawn) + // never see the 'close' event because the emulator holds the pipes open. + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + + var process = new Process { StartInfo = psi }; + process.Start (); + + return process; + } + + public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) + { + var emulatorPath = RequireEmulatorPath (); + + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); + ConfigureEnvironment (psi); + + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return ParseListAvdsOutput (stdout.ToString ()); + } + + internal static List ParseListAvdsOutput (string output) + { + var avds = new List (); + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed)) + avds.Add (trimmed); + } + return avds; + } + + /// + /// Boots an emulator and waits for it to be fully booted. + /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// + public async Task BootAndWaitAsync ( + string deviceOrAvdName, + AdbRunner adbRunner, + EmulatorBootOptions? options = null, + Action? logger = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (deviceOrAvdName)) + throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName)); + if (adbRunner == null) + throw new ArgumentNullException (nameof (adbRunner)); + + options = options ?? new EmulatorBootOptions (); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + + // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial + var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); + var onlineDevice = devices.FirstOrDefault (d => + d.Status == AdbDeviceStatus.Online && + string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); + + if (onlineDevice != null) { + Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; + } + + // Phase 2: Check if AVD is already running (possibly still booting) + var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + if (runningSerial != null) { + Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + } + + // Phase 3: Launch the emulator + if (EmulatorPath == null) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", + }; + } + + Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + Process emulatorProcess; + try { + emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + } catch (Exception ex) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Failed to launch emulator: {ex.Message}", + }; + } + + // Poll for the new emulator serial to appear + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + string? newSerial = null; + while (newSerial == null) { + timeoutCts.Token.ThrowIfCancellationRequested (); + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + + devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false); + newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + } + + Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } + + static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName) + { + foreach (var d in devices) { + if (d.Type == AdbDeviceType.Emulator && + !string.IsNullOrEmpty (d.AvdName) && + string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) { + return d.Serial; + } + } + return null; + } + + async Task WaitForFullBootAsync ( + AdbRunner adbRunner, + string serial, + EmulatorBootOptions options, + Action? logger, + CancellationToken cancellationToken) + { + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + while (true) { + timeoutCts.Token.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; + } + } + + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + } + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + Serial = serial, + ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs new file mode 100644 index 00000000..490b18e7 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class EmulatorRunnerTests +{ + [Test] + public void ParseListAvdsOutput_MultipleAvds () + { + var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (3, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + Assert.AreEqual ("Nexus_5X", avds [2]); + } + + [Test] + public void ParseListAvdsOutput_EmptyOutput () + { + var avds = EmulatorRunner.ParseListAvdsOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseListAvdsOutput_WindowsNewlines () + { + var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + } + + [Test] + public void ParseListAvdsOutput_BlankLines () + { + var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + } + + [Test] + public void EmulatorPath_FindsInSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + try { + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); + + var runner = new EmulatorRunner (() => tempDir); + + Assert.IsNotNull (runner.EmulatorPath); + Assert.IsTrue (runner.IsAvailable); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void EmulatorPath_MissingSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => "/nonexistent/path"); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + [Test] + public void EmulatorPath_NullSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => null); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + + [Test] + public async Task AlreadyOnlineDevice_PassesThrough () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner (() => null); + + var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AvdAlreadyRunning_WaitsForFullBoot () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + } + + [Test] + public async Task BootEmulator_AppearsAfterPolling () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + int pollCount = 0; + mockAdb.OnListDevices = () => { + pollCount++; + if (pollCount >= 2) { + devices.Add (new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }); + } + }; + + var tempDir = CreateFakeEmulatorSdk (); + try { + var runner = new EmulatorRunner (() => tempDir); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (10), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsTrue (pollCount >= 2); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchFailure_ReturnsError () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + // No emulator path → EmulatorPath returns null → error + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task BootTimeout_BootCompletedNeverReaches1 () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + // boot_completed never returns "1" + mockAdb.ShellProperties ["sys.boot_completed"] = "0"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (200), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task MultipleEmulators_FindsCorrectAvd () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_5_API_30", + }, + new AdbDeviceInfo { + Serial = "emulator-5556", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + new AdbDeviceInfo { + Serial = "emulator-5558", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Nexus_5X_API_28", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); + } + + // --- Helpers --- + + static string CreateFakeEmulatorSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuPath = Path.Combine (emulatorDir, emuName); + // Create a fake emulator script that just exits + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + // Make executable + var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; + Process.Start (psi)?.WaitForExit (); + } + + return tempDir; + } + + /// + /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// + class MockAdbRunner : AdbRunner + { + readonly List devices; + + public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Action? OnListDevices { get; set; } + + public MockAdbRunner (List devices) + : base ("/fake/adb") + { + this.devices = devices; + } + + public override Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + OnListDevices?.Invoke (); + return Task.FromResult> (devices); + } + + public override Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + ShellProperties.TryGetValue (propertyName, out var value); + return Task.FromResult (value); + } + + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + ShellCommands.TryGetValue (command, out var value); + return Task.FromResult (value); + } + } +} From d0d188ab4802a88987a9351fc8197f254ff92984 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 10 Mar 2026 12:50:57 +0000 Subject: [PATCH 2/9] Fix EmulatorPath Windows lookup and test fake to use .bat On Windows, the fake emulator was created as emulator.exe with batch script content (@echo off), which is not a valid PE binary. Process.Start() throws Win32Exception when trying to execute it, causing BootEmulator_AppearsAfterPolling to fail. Fix: - EmulatorPath now prefers .exe, falls back to .bat/.cmd on Windows (matching how older Android SDK tools ship batch wrappers) - Test fake creates emulator.bat instead of emulator.exe on Windows, with a proper idle command (ping -n 60) so the process stays alive during the polling test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 14 ++++++++++++-- .../EmulatorRunnerTests.cs | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 837108f5..eb5fe5e0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -36,9 +36,19 @@ public string? EmulatorPath { if (string.IsNullOrEmpty (sdkPath)) return null; - var ext = OS.IsWindows ? ".exe" : ""; - var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + var emulatorDir = Path.Combine (sdkPath, "emulator"); + + if (OS.IsWindows) { + // Prefer .exe, fall back to .bat/.cmd (older SDK versions) + foreach (var ext in new [] { ".exe", ".bat", ".cmd" }) { + var candidate = Path.Combine (emulatorDir, "emulator" + ext); + if (File.Exists (candidate)) + return candidate; + } + return null; + } + var path = Path.Combine (emulatorDir, "emulator"); return File.Exists (path) ? path : null; } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 490b18e7..dd12c6bf 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -273,11 +273,11 @@ static string CreateFakeEmulatorSdk () var emulatorDir = Path.Combine (tempDir, "emulator"); Directory.CreateDirectory (emulatorDir); - var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; var emuPath = Path.Combine (emulatorDir, emuName); - // Create a fake emulator script that just exits + // Create a fake emulator script that just idles if (OS.IsWindows) { - File.WriteAllText (emuPath, "@echo off"); + File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); } else { File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); // Make executable From 5fb7e6e61209cec4ff480d7152908e8d0306b719 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 11 Mar 2026 20:01:25 +0000 Subject: [PATCH 3/9] Address review findings: structured args, record types, pipe draining, NUnit constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmulatorBootOptions.AdditionalArgs: string? → IEnumerable? (prevents single-arg bug) - EmulatorBootResult: class → record with init properties, file-scoped namespace - EmulatorBootOptions: file-scoped namespace - StartAvd: drain redirected stdout/stderr with BeginOutputReadLine/BeginErrorReadLine - Cache Windows emulator extensions as static readonly array - Tests: replace null-forgiving '!' with Assert.That + Does.Contain - Tests: use ProcessUtils.CreateProcessStartInfo for chmod instead of raw ProcessStartInfo - Add PublicAPI.Unshipped.txt entries for all new public types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/EmulatorBootOptions.cs | 22 +-- .../Models/EmulatorBootResult.cs | 19 ++- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 27 +++- .../netstandard2.0/PublicAPI.Unshipped.txt | 27 +++- .../Runners/AndroidEnvironmentHelper.cs | 11 -- .../Runners/EmulatorRunner.cs | 133 +++++++++--------- .../EmulatorRunnerTests.cs | 107 ++++++++------ 7 files changed, 202 insertions(+), 144 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs index 8765df77..7c011fde 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -2,17 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; -namespace Xamarin.Android.Tools +namespace Xamarin.Android.Tools; + +/// +/// Options for booting an Android emulator. +/// +public class EmulatorBootOptions { - /// - /// Options for booting an Android emulator. - /// - public class EmulatorBootOptions - { - public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); - public string? AdditionalArgs { get; set; } - public bool ColdBoot { get; set; } - public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); - } + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public IEnumerable? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs index 59281792..4316f8b6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -1,15 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Xamarin.Android.Tools +namespace Xamarin.Android.Tools; + +/// +/// Result of an emulator boot operation. +/// +public record EmulatorBootResult { - /// - /// Result of an emulator boot operation. - /// - public class EmulatorBootResult - { - public bool Success { get; set; } - public string? Serial { get; set; } - public string? ErrorMessage { get; set; } - } + public bool Success { get; init; } + public string? Serial { get; init; } + public string? ErrorMessage { get; init; } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 03a3352e..3cd52f61 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -33,7 +33,8 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +146,27 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 03a3352e..3cd52f61 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -33,7 +33,8 @@ Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDevic Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +146,27 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void +Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 3cf67207..51f6309d 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -37,15 +37,4 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP return env; } - - /// - /// Applies Android SDK environment variables directly to a . - /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner). - /// - internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath) - { - var env = GetEnvironmentVariables (sdkPath, jdkPath); - foreach (var kvp in env) - psi.Environment [kvp.Key] = kvp.Value; - } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index eb5fe5e0..615d19e7 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -16,67 +16,34 @@ namespace Xamarin.Android.Tools; /// public class EmulatorRunner { - readonly Func getSdkPath; - readonly Func? getJdkPath; + readonly string emulatorPath; + readonly IDictionary? environmentVariables; + readonly Action? logger; - public EmulatorRunner (Func getSdkPath) - : this (getSdkPath, null) - { - } - - public EmulatorRunner (Func getSdkPath, Func? getJdkPath) - { - this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); - this.getJdkPath = getJdkPath; - } - - public string? EmulatorPath { - get { - var sdkPath = getSdkPath (); - if (string.IsNullOrEmpty (sdkPath)) - return null; - - var emulatorDir = Path.Combine (sdkPath, "emulator"); - - if (OS.IsWindows) { - // Prefer .exe, fall back to .bat/.cmd (older SDK versions) - foreach (var ext in new [] { ".exe", ".bat", ".cmd" }) { - var candidate = Path.Combine (emulatorDir, "emulator" + ext); - if (File.Exists (candidate)) - return candidate; - } - return null; - } - - var path = Path.Combine (emulatorDir, "emulator"); - return File.Exists (path) ? path : null; - } - } - - public bool IsAvailable => EmulatorPath is not null; - - string RequireEmulatorPath () - { - return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); - } - - void ConfigureEnvironment (ProcessStartInfo psi) + /// + /// Creates a new EmulatorRunner with the full path to the emulator executable. + /// + /// Full path to the emulator executable (e.g., "/path/to/sdk/emulator/emulator"). + /// Optional environment variables to pass to emulator processes. + /// Optional logger callback for diagnostic messages. + public EmulatorRunner (string emulatorPath, IDictionary? environmentVariables = null, Action? logger = null) { - AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + if (string.IsNullOrWhiteSpace (emulatorPath)) + throw new ArgumentException ("Path to emulator must not be empty.", nameof (emulatorPath)); + this.emulatorPath = emulatorPath; + this.environmentVariables = environmentVariables; + this.logger = logger; } - public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { - var emulatorPath = RequireEmulatorPath (); - var args = new List { "-avd", avdName }; if (coldBoot) args.Add ("-no-snapshot-load"); - if (!string.IsNullOrEmpty (additionalArgs)) - args.Add (additionalArgs); + if (additionalArgs != null) + args.AddRange (additionalArgs); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); - ConfigureEnvironment (psi); // Redirect stdout/stderr so the emulator process doesn't inherit the // caller's pipes. Without this, parent processes (e.g. VS Code spawn) @@ -84,21 +51,37 @@ public Process StartAvd (string avdName, bool coldBoot = false, string? addition psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; + logger?.Invoke (TraceLevel.Verbose, $"Starting emulator AVD '{avdName}'"); + var process = new Process { StartInfo = psi }; + + // Forward emulator output to the logger so crash messages (e.g. "HAX is + // not working", "image not found") are captured instead of silently lost. + process.OutputDataReceived += (_, e) => { + if (e.Data != null) + logger?.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); + }; + process.ErrorDataReceived += (_, e) => { + if (e.Data != null) + logger?.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); + }; + process.Start (); + // Drain redirected streams asynchronously to prevent pipe buffer deadlocks + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + return process; } public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) { - var emulatorPath = RequireEmulatorPath (); - using var stdout = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); - ConfigureEnvironment (psi); - await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + logger?.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); return ParseListAvdsOutput (stdout.ToString ()); } @@ -122,7 +105,6 @@ public async Task BootAndWaitAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, - Action? logger = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace (deviceOrAvdName)) @@ -130,7 +112,12 @@ public async Task BootAndWaitAsync ( if (adbRunner == null) throw new ArgumentNullException (nameof (adbRunner)); - options = options ?? new EmulatorBootOptions (); + options ??= new EmulatorBootOptions (); + if (options.BootTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "BootTimeout must be positive."); + if (options.PollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "PollInterval must be positive."); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); @@ -150,17 +137,10 @@ public async Task BootAndWaitAsync ( var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, cancellationToken).ConfigureAwait (false); } // Phase 3: Launch the emulator - if (EmulatorPath == null) { - return new EmulatorBootResult { - Success = false, - ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", - }; - } - Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { @@ -172,7 +152,9 @@ public async Task BootAndWaitAsync ( }; } - // Poll for the new emulator serial to appear + // Poll for the new emulator serial to appear. + // If the boot times out or is cancelled, terminate the process we spawned + // to avoid leaving orphan emulator processes. using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); timeoutCts.CancelAfter (options.BootTimeout); @@ -187,12 +169,16 @@ public async Task BootAndWaitAsync ( } Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + return await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + TryKillProcess (emulatorProcess); return new EmulatorBootResult { Success = false, ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", }; + } catch { + TryKillProcess (emulatorProcess); + throw; } } @@ -208,11 +194,22 @@ public async Task BootAndWaitAsync ( return null; } + static void TryKillProcess (Process process) + { + try { + if (!process.HasExited) + process.Kill (); + } catch { + // Best-effort: process may have already exited between check and kill + } finally { + process.Dispose (); + } + } + async Task WaitForFullBootAsync ( AdbRunner adbRunner, string serial, EmulatorBootOptions options, - Action? logger, CancellationToken cancellationToken) { void Log (TraceLevel level, string message) => logger?.Invoke (level, message); diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index dd12c6bf..3d672132 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -59,39 +59,21 @@ public void ParseListAvdsOutput_BlankLines () } [Test] - public void EmulatorPath_FindsInSdk () + public void Constructor_ThrowsOnNullPath () { - var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); - var emulatorDir = Path.Combine (tempDir, "emulator"); - Directory.CreateDirectory (emulatorDir); - - try { - var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; - File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); - - var runner = new EmulatorRunner (() => tempDir); - - Assert.IsNotNull (runner.EmulatorPath); - Assert.IsTrue (runner.IsAvailable); - } finally { - Directory.Delete (tempDir, true); - } + Assert.Throws (() => new EmulatorRunner (null!)); } [Test] - public void EmulatorPath_MissingSdk_ReturnsNull () + public void Constructor_ThrowsOnEmptyPath () { - var runner = new EmulatorRunner (() => "/nonexistent/path"); - Assert.IsNull (runner.EmulatorPath); - Assert.IsFalse (runner.IsAvailable); + Assert.Throws (() => new EmulatorRunner ("")); } [Test] - public void EmulatorPath_NullSdk_ReturnsNull () + public void Constructor_ThrowsOnWhitespacePath () { - var runner = new EmulatorRunner (() => null); - Assert.IsNull (runner.EmulatorPath); - Assert.IsFalse (runner.IsAvailable); + Assert.Throws (() => new EmulatorRunner (" ")); } // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- @@ -109,7 +91,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () }; var mockAdb = new MockAdbRunner (devices); - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); @@ -134,7 +116,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () mockAdb.ShellProperties ["sys.boot_completed"] = "1"; mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); @@ -164,9 +146,10 @@ public async Task BootEmulator_AppearsAfterPolling () } }; - var tempDir = CreateFakeEmulatorSdk (); + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + Process? emulatorProcess = null; try { - var runner = new EmulatorRunner (() => tempDir); + var runner = new EmulatorRunner (emuPath); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (10), PollInterval = TimeSpan.FromMilliseconds (50), @@ -178,6 +161,12 @@ public async Task BootEmulator_AppearsAfterPolling () Assert.AreEqual ("emulator-5554", result.Serial); Assert.IsTrue (pollCount >= 2); } finally { + // Kill any emulator process spawned by the test + try { + emulatorProcess = FindEmulatorProcess (emuPath); + emulatorProcess?.Kill (); + emulatorProcess?.WaitForExit (1000); + } catch { } Directory.Delete (tempDir, true); } } @@ -188,15 +177,14 @@ public async Task LaunchFailure_ReturnsError () var devices = new List (); var mockAdb = new MockAdbRunner (devices); - // No emulator path → EmulatorPath returns null → error - var runner = new EmulatorRunner (() => null); + // Nonexistent path → StartAvd throws → error result + var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); - Assert.IsNotNull (result.ErrorMessage); - Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); } [Test] @@ -215,7 +203,7 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () // boot_completed never returns "1" mockAdb.ShellProperties ["sys.boot_completed"] = "0"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromMilliseconds (200), PollInterval = TimeSpan.FromMilliseconds (50), @@ -224,8 +212,29 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); - Assert.IsNotNull (result.ErrorMessage); - Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); + } + + [Test] + public void BootAndWaitAsync_InvalidBootTimeout_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; + + Assert.ThrowsAsync (() => + runner.BootAndWaitAsync ("test", mockAdb, options)); + } + + [Test] + public void BootAndWaitAsync_InvalidPollInterval_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; + + Assert.ThrowsAsync (() => + runner.BootAndWaitAsync ("test", mockAdb, options)); } [Test] @@ -256,7 +265,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () mockAdb.ShellProperties ["sys.boot_completed"] = "1"; mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; - var runner = new EmulatorRunner (() => null); + var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); @@ -267,7 +276,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () // --- Helpers --- - static string CreateFakeEmulatorSdk () + static (string tempDir, string emulatorPath) CreateFakeEmulatorSdk () { var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); var emulatorDir = Path.Combine (tempDir, "emulator"); @@ -275,17 +284,31 @@ static string CreateFakeEmulatorSdk () var emuName = OS.IsWindows ? "emulator.bat" : "emulator"; var emuPath = Path.Combine (emulatorDir, emuName); - // Create a fake emulator script that just idles if (OS.IsWindows) { File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); } else { File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); - // Make executable - var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; - Process.Start (psi)?.WaitForExit (); + var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); + using var chmod = new Process { StartInfo = psi }; + chmod.Start (); + chmod.WaitForExit (); } - return tempDir; + return (tempDir, emuPath); + } + + static Process? FindEmulatorProcess (string emuPath) + { + // Best-effort: find the process by matching the command line + try { + foreach (var p in Process.GetProcessesByName ("emulator")) { + return p; + } + foreach (var p in Process.GetProcessesByName ("sleep")) { + return p; + } + } catch { } + return null; } /// From 7e42effe23b034b62c6fdf001f266b51a20b48d3 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 12 Mar 2026 18:17:18 +0000 Subject: [PATCH 4/9] Fix env vars, timeout duplication, and process tree cleanup - Apply constructor environmentVariables to ProcessStartInfo in StartAvd - Move timeoutCts before Phase 2 so both Phase 2 (AVD already running) and Phase 3 (launch emulator) share a single boot timeout - Remove dead try-catch from WaitForFullBootAsync (callers handle timeout) - Use Process.Kill(entireProcessTree: true) on .NET 5+ to clean up child processes on Linux/macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 615d19e7..223939bb 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -45,6 +45,11 @@ public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable BootAndWaitAsync ( return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; } + // Single timeout CTS for the entire boot operation (covers Phase 2 and Phase 3). + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + // Phase 2: Check if AVD is already running (possibly still booting) var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); if (runningSerial != null) { Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, runningSerial, options, cancellationToken).ConfigureAwait (false); + try { + return await WaitForFullBootAsync (adbRunner, runningSerial, options, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } } // Phase 3: Launch the emulator @@ -155,9 +171,6 @@ public async Task BootAndWaitAsync ( // Poll for the new emulator serial to appear. // If the boot times out or is cancelled, terminate the process we spawned // to avoid leaving orphan emulator processes. - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); - timeoutCts.CancelAfter (options.BootTimeout); - try { string? newSerial = null; while (newSerial == null) { @@ -197,8 +210,13 @@ public async Task BootAndWaitAsync ( static void TryKillProcess (Process process) { try { - if (!process.HasExited) + if (!process.HasExited) { +#if NET5_0_OR_GREATER + process.Kill (entireProcessTree: true); +#else process.Kill (); +#endif + } } catch { // Best-effort: process may have already exited between check and kill } finally { @@ -214,30 +232,22 @@ async Task WaitForFullBootAsync ( { void Log (TraceLevel level, string message) => logger?.Invoke (level, message); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); - timeoutCts.CancelAfter (options.BootTimeout); - - try { - while (true) { - timeoutCts.Token.ThrowIfCancellationRequested (); - - var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); - if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { - var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); - if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { - Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); - return new EmulatorBootResult { Success = true, Serial = serial }; - } + // The caller is responsible for enforcing the overall boot timeout via + // cancellationToken (a linked CTS with CancelAfter). This method simply + // polls until boot completes or the token is cancelled. + while (true) { + cancellationToken.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", cancellationToken).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; } - - await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); } - } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return new EmulatorBootResult { - Success = false, - Serial = serial, - ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", - }; + + await Task.Delay (options.PollInterval, cancellationToken).ConfigureAwait (false); } } } From 97c05e0498097c467c22a284e9e60109fe4e9b11 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 09:49:01 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Rename=20StartAvd=E2=86=92LaunchAvd=20and?= =?UTF-8?q?=20BootAndWaitAsync=E2=86=92BootAvdAsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve public API naming for EmulatorRunner: - LaunchAvd: fire-and-forget process spawn, returns Process immediately - BootAvdAsync: full lifecycle — launch + poll until fully booted - Add comprehensive XML documentation explaining the behavioral difference between the two methods - Update PublicAPI surface files and all test references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 4 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 4 +- .../Runners/EmulatorRunner.cs | 40 ++++++++++++++++--- .../EmulatorRunnerTests.cs | 26 ++++++------ 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 3cd52f61..37d3586c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -166,7 +166,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 3cd52f61..37d3586c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -166,7 +166,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAndWaitAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.StartAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 223939bb..d2856fe9 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -35,7 +35,18 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ this.logger = logger; } - public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) + /// + /// Launches an emulator process for the specified AVD and returns immediately. + /// The returned represents the running emulator — the caller + /// is responsible for managing its lifetime (e.g., killing it on shutdown). + /// This method does not wait for the emulator to finish booting. + /// To launch and wait until the device is fully booted, use instead. + /// + /// Name of the AVD to launch (as shown by emulator -list-avds). + /// When true, forces a cold boot by passing -no-snapshot-load. + /// Optional extra arguments to pass to the emulator command line. + /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. + public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { var args = new List { "-avd", avdName }; if (coldBoot) @@ -56,7 +67,7 @@ public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable ParseListAvdsOutput (string output) } /// - /// Boots an emulator and waits for it to be fully booted. - /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// Boots an emulator for the specified AVD and waits until it is fully ready to accept commands. + /// + /// Unlike , which only spawns the emulator process, this method + /// handles the full lifecycle: it checks whether the device is already online, launches + /// the emulator if needed, then polls sys.boot_completed and pm path android + /// until the Android OS is fully booted and the package manager is responsive. + /// + /// Ported from the dotnet/android BootAndroidEmulator MSBuild task. /// - public async Task BootAndWaitAsync ( + /// + /// Either an ADB device serial (e.g., emulator-5554) to wait for, + /// or an AVD name (e.g., Pixel_7_API_35) to launch and boot. + /// + /// An used to query device status and boot properties. + /// Optional boot configuration (timeout, poll interval, cold boot, extra args). + /// Cancellation token to abort the operation. + /// + /// An indicating success or failure, including the device serial on success + /// or an error message on timeout/failure. + /// + public async Task BootAvdAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, @@ -160,7 +188,7 @@ public async Task BootAndWaitAsync ( Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { - emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + emulatorProcess = LaunchAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); } catch (Exception ex) { return new EmulatorBootResult { Success = false, diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index 3d672132..b260de2a 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -76,7 +76,7 @@ public void Constructor_ThrowsOnWhitespacePath () Assert.Throws (() => new EmulatorRunner (" ")); } - // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] public async Task AlreadyOnlineDevice_PassesThrough () @@ -93,7 +93,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () var mockAdb = new MockAdbRunner (devices); var runner = new EmulatorRunner ("/fake/emulator"); - var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + var result = await runner.BootAvdAsync ("emulator-5554", mockAdb); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -119,7 +119,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -155,7 +155,7 @@ public async Task BootEmulator_AppearsAfterPolling () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -177,11 +177,11 @@ public async Task LaunchFailure_ReturnsError () var devices = new List (); var mockAdb = new MockAdbRunner (devices); - // Nonexistent path → StartAvd throws → error result + // Nonexistent path → LaunchAvd throws → error result var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); @@ -209,32 +209,32 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); } [Test] - public void BootAndWaitAsync_InvalidBootTimeout_Throws () + public void BootAvdAsync_InvalidBootTimeout_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; Assert.ThrowsAsync (() => - runner.BootAndWaitAsync ("test", mockAdb, options)); + runner.BootAvdAsync ("test", mockAdb, options)); } [Test] - public void BootAndWaitAsync_InvalidPollInterval_Throws () + public void BootAvdAsync_InvalidPollInterval_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; Assert.ThrowsAsync (() => - runner.BootAndWaitAsync ("test", mockAdb, options)); + runner.BootAvdAsync ("test", mockAdb, options)); } [Test] @@ -268,7 +268,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); @@ -312,7 +312,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () } /// - /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// Mock AdbRunner for testing BootAvdAsync without real adb commands. /// class MockAdbRunner : AdbRunner { From b4739177335cfbb3ed10fe546437aa8ac9be5d44 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 16:10:03 +0000 Subject: [PATCH 6/9] Address review findings: validation, logging, tests Fix #1: Validate avdName in LaunchAvd (ArgumentException on null/empty) Fix #2: RunShellCommandAsync now returns full trimmed stdout (not just first line) Fix #3: Add 9 FirstNonEmptyLine parsing tests + 3 LaunchAvd validation tests Fix #5: Log stderr via logger on shell command failures (AdbRunner gets logger param) Fix #6: Remove TOCTOU HasExited check in TryKillProcess (rely on catch block) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../Runners/AdbRunner.cs | 24 +++++++- .../Runners/EmulatorRunner.cs | 11 ++-- .../AdbRunnerTests.cs | 59 +++++++++++++++++++ .../EmulatorRunnerTests.cs | 21 +++++++ 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 37d3586c..10b52aa6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -32,7 +32,7 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void *REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 37d3586c..10b52aa6 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -32,7 +32,7 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void *REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index da2e9799..f4d1844b 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -21,6 +21,7 @@ public class AdbRunner { readonly string adbPath; readonly IDictionary? environmentVariables; + readonly Action? logger; // Pattern to match device lines: [key:value ...] // Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields. @@ -35,12 +36,14 @@ public class AdbRunner /// /// Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb"). /// Optional environment variables to pass to adb processes. - public AdbRunner (string adbPath, IDictionary? environmentVariables = null) + /// Optional logger callback for diagnostic messages. + public AdbRunner (string adbPath, IDictionary? environmentVariables = null, Action? logger = null) { if (string.IsNullOrWhiteSpace (adbPath)) throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); this.adbPath = adbPath; this.environmentVariables = environmentVariables; + this.logger = logger; } /// @@ -137,6 +140,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// Returns the property value (first non-empty line of stdout), or null on failure. /// public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) { @@ -144,11 +148,18 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); - return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}"); + return null; + } + return FirstNonEmptyLine (stdout.ToString ()); } /// /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// Returns the full stdout output trimmed, or null on failure. /// public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) { @@ -156,7 +167,14 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); - return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; } internal static string? FirstNonEmptyLine (string output) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index d2856fe9..6e7233b5 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -48,6 +48,9 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { + if (string.IsNullOrWhiteSpace (avdName)) + throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); + var args = new List { "-avd", avdName }; if (coldBoot) args.Add ("-no-snapshot-load"); @@ -238,15 +241,13 @@ public async Task BootAvdAsync ( static void TryKillProcess (Process process) { try { - if (!process.HasExited) { #if NET5_0_OR_GREATER - process.Kill (entireProcessTree: true); + process.Kill (entireProcessTree: true); #else - process.Kill (); + process.Kill (); #endif - } } catch { - // Best-effort: process may have already exited between check and kill + // Best-effort: process may have already exited } finally { process.Dispose (); } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 1e9a232a..c9536cf9 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -655,4 +655,63 @@ public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () Assert.ThrowsAsync ( async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero)); } + + // --- FirstNonEmptyLine tests --- + + [Test] + public void FirstNonEmptyLine_ReturnsFirstLine () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("hello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_SkipsBlankLines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\n\nhello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_TrimsWhitespace () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine (" hello \n")); + } + + [Test] + public void FirstNonEmptyLine_HandlesWindowsNewlines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\r\nhello\r\nworld\r\n")); + } + + [Test] + public void FirstNonEmptyLine_EmptyString_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("")); + } + + [Test] + public void FirstNonEmptyLine_OnlyNewlines_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("\n\n\n")); + } + + [Test] + public void FirstNonEmptyLine_SingleValue_ReturnsIt () + { + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\n")); + } + + [Test] + public void FirstNonEmptyLine_TypicalGetpropOutput () + { + // adb shell getprop sys.boot_completed returns "1\n" or "1\r\n" + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\r\n")); + } + + [Test] + public void FirstNonEmptyLine_PmPathOutput () + { + // adb shell pm path android returns "package:/system/framework/framework-res.apk\n" + var output = "package:/system/framework/framework-res.apk\n"; + Assert.AreEqual ("package:/system/framework/framework-res.apk", AdbRunner.FirstNonEmptyLine (output)); + } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index b260de2a..ba8ed996 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -76,6 +76,27 @@ public void Constructor_ThrowsOnWhitespacePath () Assert.Throws (() => new EmulatorRunner (" ")); } + [Test] + public void LaunchAvd_ThrowsOnNullAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd (null!)); + } + + [Test] + public void LaunchAvd_ThrowsOnEmptyAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd ("")); + } + + [Test] + public void LaunchAvd_ThrowsOnWhitespaceAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchAvd (" ")); + } + // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] From 21f8b94f4aec78de3b9964b58c76bb4c5fc29f6e Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 16:44:28 +0000 Subject: [PATCH 7/9] Fix Process handle leak on successful BootAvdAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispose the Process wrapper on the success path so file handles and native resources are released. The emulator OS process keeps running — only the .NET Process handle is freed. Failure/timeout paths already dispose via TryKillProcess. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/EmulatorRunner.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 6e7233b5..c6a9db11 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -213,7 +213,12 @@ public async Task BootAvdAsync ( } Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); - return await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); + var result = await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); + + // Release the Process handle — the emulator process itself keeps running. + // We no longer need stdout/stderr forwarding since boot is complete. + emulatorProcess.Dispose (); + return result; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { TryKillProcess (emulatorProcess); return new EmulatorBootResult { From f19b01426c248d2ab3a7471392246ad34dc97501 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 17:14:08 +0000 Subject: [PATCH 8/9] Address bot review: exit code check, typed catch, shell doc - ListAvdNamesAsync: check exit code from emulator -list-avds - TryKillProcess: change bare catch to catch(Exception ex) with logger - TryKillProcess: make instance method to access logger field - RunShellCommandAsync: add XML doc warning about shell interpretation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 5 +++++ .../Runners/EmulatorRunner.cs | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index f4d1844b..741c3951 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -161,6 +161,11 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. /// Returns the full stdout output trimmed, or null on failure. /// + /// + /// The is passed as a single argument to adb shell, + /// which means the device's shell interprets it (shell expansion, pipes, semicolons are active). + /// Do not pass untrusted or user-supplied input without proper validation. + /// public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index c6a9db11..557ab823 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -97,10 +97,12 @@ public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable> ListAvdNamesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); + using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); logger?.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); - await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, "emulator -list-avds", stderr); return ParseListAvdsOutput (stdout.ToString ()); } @@ -243,7 +245,7 @@ public async Task BootAvdAsync ( return null; } - static void TryKillProcess (Process process) + void TryKillProcess (Process process) { try { #if NET5_0_OR_GREATER @@ -251,8 +253,9 @@ static void TryKillProcess (Process process) #else process.Kill (); #endif - } catch { + } catch (Exception ex) { // Best-effort: process may have already exited + logger?.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); } finally { process.Dispose (); } From d8ee2d591afb4175c5ebf75e8ca261ed9c6685ff Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 18:05:43 +0000 Subject: [PATCH 9/9] =?UTF-8?q?Rename=20LaunchAvd=E2=86=92LaunchEmulator,?= =?UTF-8?q?=20BootAvdAsync=E2=86=92BootEmulatorAsync,=20add=20structured?= =?UTF-8?q?=20RunShellCommandAsync=20overload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename LaunchAvd to LaunchEmulator (fire-and-forget) - Rename BootAvdAsync to BootEmulatorAsync (full lifecycle) - Add RunShellCommandAsync(serial, command, args, ct) overload that passes args as separate tokens (exec, no shell interpretation) - Fix RS0026/RS0027: only the most-params overload has optional ct - Update all tests and PublicAPI files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 7 ++-- .../netstandard2.0/PublicAPI.Unshipped.txt | 7 ++-- .../Runners/AdbRunner.cs | 34 ++++++++++++++++- .../Runners/EmulatorRunner.cs | 10 ++--- .../EmulatorRunnerTests.cs | 38 +++++++++---------- 5 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 10b52aa6..484b7243 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -147,7 +147,8 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void @@ -166,7 +167,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 10b52aa6..484b7243 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -147,7 +147,8 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorBootOptions Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable? Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void @@ -166,7 +167,7 @@ Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void Xamarin.Android.Tools.EmulatorRunner -Xamarin.Android.Tools.EmulatorRunner.BootAvdAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! -Xamarin.Android.Tools.EmulatorRunner.LaunchAvd(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 741c3951..5437ce8f 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -166,7 +166,7 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// which means the device's shell interprets it (shell expansion, pipes, semicolons are active). /// Do not pass untrusted or user-supplied input without proper validation. /// - public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -182,6 +182,38 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati return output.Length > 0 ? output : null; } + /// + /// Runs a shell command on a device via adb -s <serial> shell <command> <args>. + /// Returns the full stdout output trimmed, or null on failure. + /// + /// + /// When adb shell receives the command and arguments as separate tokens, it uses + /// exec() directly on the device — bypassing the device's shell interpreter. + /// This avoids shell expansion, pipes, and injection risks, making it safer for dynamic input. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, string[] args, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + // Build: adb -s shell ... + var allArgs = new string [3 + 1 + args.Length]; + allArgs [0] = "-s"; + allArgs [1] = serial; + allArgs [2] = "shell"; + allArgs [3] = command; + Array.Copy (args, 0, allArgs, 4, args.Length); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, allArgs); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; + } + internal static string? FirstNonEmptyLine (string output) { foreach (var line in output.Split ('\n')) { diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs index 557ab823..83f67ea5 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -40,13 +40,13 @@ public EmulatorRunner (string emulatorPath, IDictionary? environ /// The returned represents the running emulator — the caller /// is responsible for managing its lifetime (e.g., killing it on shutdown). /// This method does not wait for the emulator to finish booting. - /// To launch and wait until the device is fully booted, use instead. + /// To launch and wait until the device is fully booted, use instead. /// /// Name of the AVD to launch (as shown by emulator -list-avds). /// When true, forces a cold boot by passing -no-snapshot-load. /// Optional extra arguments to pass to the emulator command line. /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. - public Process LaunchAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) + public Process LaunchEmulator (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null) { if (string.IsNullOrWhiteSpace (avdName)) throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); @@ -121,7 +121,7 @@ internal static List ParseListAvdsOutput (string output) /// /// Boots an emulator for the specified AVD and waits until it is fully ready to accept commands. /// - /// Unlike , which only spawns the emulator process, this method + /// Unlike , which only spawns the emulator process, this method /// handles the full lifecycle: it checks whether the device is already online, launches /// the emulator if needed, then polls sys.boot_completed and pm path android /// until the Android OS is fully booted and the package manager is responsive. @@ -139,7 +139,7 @@ internal static List ParseListAvdsOutput (string output) /// An indicating success or failure, including the device serial on success /// or an error message on timeout/failure. /// - public async Task BootAvdAsync ( + public async Task BootEmulatorAsync ( string deviceOrAvdName, AdbRunner adbRunner, EmulatorBootOptions? options = null, @@ -193,7 +193,7 @@ public async Task BootAvdAsync ( Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); Process emulatorProcess; try { - emulatorProcess = LaunchAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); } catch (Exception ex) { return new EmulatorBootResult { Success = false, diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs index ba8ed996..f095a872 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -77,27 +77,27 @@ public void Constructor_ThrowsOnWhitespacePath () } [Test] - public void LaunchAvd_ThrowsOnNullAvdName () + public void LaunchEmulator_ThrowsOnNullAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd (null!)); + Assert.Throws (() => runner.LaunchEmulator (null!)); } [Test] - public void LaunchAvd_ThrowsOnEmptyAvdName () + public void LaunchEmulator_ThrowsOnEmptyAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd ("")); + Assert.Throws (() => runner.LaunchEmulator ("")); } [Test] - public void LaunchAvd_ThrowsOnWhitespaceAvdName () + public void LaunchEmulator_ThrowsOnWhitespaceAvdName () { var runner = new EmulatorRunner ("/fake/emulator"); - Assert.Throws (() => runner.LaunchAvd (" ")); + Assert.Throws (() => runner.LaunchEmulator (" ")); } - // --- BootAvdAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + // --- BootEmulatorAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- [Test] public async Task AlreadyOnlineDevice_PassesThrough () @@ -114,7 +114,7 @@ public async Task AlreadyOnlineDevice_PassesThrough () var mockAdb = new MockAdbRunner (devices); var runner = new EmulatorRunner ("/fake/emulator"); - var result = await runner.BootAvdAsync ("emulator-5554", mockAdb); + var result = await runner.BootEmulatorAsync ("emulator-5554", mockAdb); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -140,7 +140,7 @@ public async Task AvdAlreadyRunning_WaitsForFullBoot () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -176,7 +176,7 @@ public async Task BootEmulator_AppearsAfterPolling () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5554", result.Serial); @@ -202,7 +202,7 @@ public async Task LaunchFailure_ReturnsError () var runner = new EmulatorRunner ("/nonexistent/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); @@ -230,32 +230,32 @@ public async Task BootTimeout_BootCompletedNeverReaches1 () PollInterval = TimeSpan.FromMilliseconds (50), }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsFalse (result.Success); Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); } [Test] - public void BootAvdAsync_InvalidBootTimeout_Throws () + public void BootEmulatorAsync_InvalidBootTimeout_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; Assert.ThrowsAsync (() => - runner.BootAvdAsync ("test", mockAdb, options)); + runner.BootEmulatorAsync ("test", mockAdb, options)); } [Test] - public void BootAvdAsync_InvalidPollInterval_Throws () + public void BootEmulatorAsync_InvalidPollInterval_Throws () { var runner = new EmulatorRunner ("/fake/emulator"); var mockAdb = new MockAdbRunner (new List ()); var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; Assert.ThrowsAsync (() => - runner.BootAvdAsync ("test", mockAdb, options)); + runner.BootEmulatorAsync ("test", mockAdb, options)); } [Test] @@ -289,7 +289,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () var runner = new EmulatorRunner ("/fake/emulator"); var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; - var result = await runner.BootAvdAsync ("Pixel_7_API_35", mockAdb, options); + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); Assert.IsTrue (result.Success); Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); @@ -333,7 +333,7 @@ public async Task MultipleEmulators_FindsCorrectAvd () } /// - /// Mock AdbRunner for testing BootAvdAsync without real adb commands. + /// Mock AdbRunner for testing BootEmulatorAsync without real adb commands. /// class MockAdbRunner : AdbRunner { @@ -361,7 +361,7 @@ public override Task> ListDevicesAsync (Cancellatio return Task.FromResult (value); } - public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken) { ShellCommands.TryGetValue (command, out var value); return Task.FromResult (value);