diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index d936677798b..bc2d9a7d4d4 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -558,7 +558,7 @@ public static string XA0143 { } /// - /// Looks up a localized string similar to The Android emulator for AVD '{0}' exited unexpectedly with exit code {1} before becoming available.. + /// Looks up a localized string similar to The Android emulator for AVD '{0}' failed with error '{1}': {2}. /// public static string XA0144 { get { diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index bb5e38b7164..d1ee02dc051 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1079,9 +1079,10 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS {1} - The exception message. - The Android emulator for AVD '{0}' exited unexpectedly with exit code {1} before becoming available. + The Android emulator for AVD '{0}' failed with error '{1}': {2} {0} - The AVD name. -{1} - The process exit code. +{1} - The error kind (e.g. Unknown). +{2} - Error details from the emulator runner. The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration. diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index bcfa0d656de..afbe8ff919b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Threading; +using System.Threading.Tasks; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Tools; @@ -24,11 +24,13 @@ namespace Xamarin.Android.Tasks; /// the emulator and waits for it to become fully ready. /// /// On success, outputs the resolved ADB serial and AdbTarget for use by subsequent tasks. +/// +/// Boot logic is delegated to and +/// in Xamarin.Android.Tools.AndroidSdk. /// -public class BootAndroidEmulator : AndroidTask +public class BootAndroidEmulator : AsyncTask { const int DefaultBootTimeoutSeconds = 300; - const int PollIntervalMilliseconds = 500; public override string TaskPrefix => "BAE"; @@ -92,74 +94,104 @@ public class BootAndroidEmulator : AndroidTask [Output] public string? AdbTarget { get; set; } - public override bool RunTask () + public override async Task RunTaskAsync () { + if (BootTimeoutSeconds <= 0) { + LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + return; + } + var adbPath = ResolveAdbPath (); + var emulatorPath = ResolveEmulatorPath (); + var logger = this.CreateTaskLogger (); - // Check if DeviceId is already a known online ADB serial - if (IsOnlineAdbDevice (adbPath, Device)) { - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is already online."); - ResolvedDevice = Device; - AdbTarget = $"-s {Device}"; - return true; - } + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (BootTimeoutSeconds), + AdditionalArgs = ParseExtraArguments (EmulatorExtraArguments), + }; - // DeviceId is not an online serial — treat it as an AVD name and boot it - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is not an online ADB device. Treating as AVD name."); + var result = await ExecuteBootAsync (adbPath, emulatorPath, logger, Device, options, CancellationToken).ConfigureAwait (false); - var emulatorPath = ResolveEmulatorPath (); - var avdName = Device; - - // Check if this AVD is already running (but perhaps still booting) - var existingSerial = FindRunningEmulatorForAvd (adbPath, avdName); - if (existingSerial != null) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' is already running as '{existingSerial}'"); - ResolvedDevice = existingSerial; - AdbTarget = $"-s {existingSerial}"; - return WaitForFullBoot (adbPath, avdName, existingSerial); + if (result.Success) { + if (result.Serial.IsNullOrEmpty ()) { + LogCodedError ("XA0143", Properties.Resources.XA0143, Device, "Boot reported success but no device serial was returned."); + return; + } + ResolvedDevice = result.Serial; + AdbTarget = $"-s {result.Serial}"; + LogMessage ($"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); + return; + } + + switch (result.ErrorKind) { + case EmulatorBootErrorKind.LaunchFailed: + LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); + break; + case EmulatorBootErrorKind.Cancelled: + LogMessage ($"Emulator boot for '{Device}' was cancelled."); + break; + case EmulatorBootErrorKind.Timeout: + LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + break; + default: + LogCodedError ("XA0144", Properties.Resources.XA0144, Device, result.ErrorKind, result.ErrorMessage ?? "Unknown error"); + break; } + } - // Launch the emulator process in the background - Log.LogMessage (MessageImportance.High, $"Booting emulator '{avdName}'..."); - using var emulatorProcess = LaunchEmulatorProcess (emulatorPath, avdName); - if (emulatorProcess == null) { - return false; - } + /// + /// Executes the full boot flow via . + /// Virtual so tests can return canned results without launching real processes. + /// + protected virtual async Task ExecuteBootAsync ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options, + System.Threading.CancellationToken cancellationToken) + { + var adbRunner = new AdbRunner (adbPath, logger: logger); + var emulatorRunner = new EmulatorRunner (emulatorPath, logger: logger); + return await emulatorRunner.BootEmulatorAsync (device, adbRunner, options, cancellationToken).ConfigureAwait (false); + } - try { - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - var stopwatch = Stopwatch.StartNew (); - - // Phase 1: Wait for the emulator to appear in 'adb devices' as online - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to appear in adb devices..."); - var serial = WaitForEmulatorOnline (adbPath, avdName, emulatorProcess, stopwatch, timeout); - if (serial == null) { - if (emulatorProcess.HasExited) { - Log.LogCodedError ("XA0144", Properties.Resources.XA0144, avdName, emulatorProcess.ExitCode); - } else { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - } - return false; - } + /// + /// Parses extra arguments into a list suitable for . + /// Supports double-quoted segments to allow values with embedded spaces (e.g. -gpu "swiftshader_indirect"). + /// Backslash-escaped quotes (\") inside quoted values are preserved as literal quote characters. + /// + static List? ParseExtraArguments (string? extraArgs) + { + if (extraArgs.IsNullOrEmpty ()) + return null; - ResolvedDevice = serial; - AdbTarget = $"-s {serial}"; - Log.LogMessage (MessageImportance.Normal, $"Emulator appeared as '{serial}'"); - - // Phase 2: Wait for the device to fully boot - return WaitForFullBoot (adbPath, avdName, serial); - } finally { - // Stop async reads and unsubscribe events; using var handles Dispose - try { - emulatorProcess.CancelOutputRead (); - emulatorProcess.CancelErrorRead (); - } catch (InvalidOperationException e) { - // Async reads may not have been started or process already exited - Log.LogDebugMessage ($"Failed to cancel async reads: {e}"); + var args = new List (); + var current = new System.Text.StringBuilder (); + bool inQuotes = false; + + for (int i = 0; i < extraArgs.Length; i++) { + char c = extraArgs [i]; + + if (c == '\\' && i + 1 < extraArgs.Length && extraArgs [i + 1] == '"') { + current.Append ('"'); + i++; + } else if (c == '"') { + inQuotes = !inQuotes; + } else if (char.IsWhiteSpace (c) && !inQuotes) { + if (current.Length > 0) { + args.Add (current.ToString ()); + current.Clear (); + } + } else { + current.Append (c); } - emulatorProcess.OutputDataReceived -= EmulatorOutputDataReceived; - emulatorProcess.ErrorDataReceived -= EmulatorErrorDataReceived; } + + if (current.Length > 0) + args.Add (current.ToString ()); + + return args.Count > 0 ? args : null; } /// @@ -193,262 +225,4 @@ string ResolveEmulatorPath () return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); } - - /// - /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. - /// - protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) - { - bool found = false; - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.Contains ("device") && !e.Data.Contains ("List of devices")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device" && - string.Equals (parts [0], deviceId, StringComparison.OrdinalIgnoreCase)) { - found = true; - } - } - }, - logWarningOnFailure: false - ); - - return found; - } - - /// - /// Checks if an emulator with the specified AVD name is already running by querying - /// 'adb devices' and then 'adb -s serial emu avd name' for each running emulator. - /// - protected virtual string? FindRunningEmulatorForAvd (string adbPath, string avdName) - { - var emulatorSerials = new List (); - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) && e.Data.Contains ("device")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device") { - emulatorSerials.Add (parts [0]); - } - } - }, - logWarningOnFailure: false - ); - - foreach (var serial in emulatorSerials) { - var name = GetRunningAvdName (adbPath, serial); - if (string.Equals (name, avdName, StringComparison.OrdinalIgnoreCase)) { - return serial; - } - } - - return null; - } - - /// - /// Gets the AVD name from a running emulator via 'adb -s serial emu avd name'. - /// - protected virtual string? GetRunningAvdName (string adbPath, string serial) - { - string? avdName = null; - try { - var outputLines = new List (); - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} emu avd name", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - outputLines.Add (e.Data); - } - }, - logWarningOnFailure: false - ); - - if (outputLines.Count > 0) { - var name = outputLines [0].Trim (); - if (!name.IsNullOrEmpty () && !name.Equals ("OK", StringComparison.OrdinalIgnoreCase)) { - avdName = name; - } - } - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get AVD name for {serial}: {ex.Message}"); - } - - return avdName; - } - - /// - /// Launches the emulator process in the background. The emulator window is shown by default, - /// but this can be customized (for example, by passing -no-window) via EmulatorExtraArguments. - /// - protected virtual Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - var arguments = $"-avd \"{avdName}\""; - if (!EmulatorExtraArguments.IsNullOrEmpty ()) { - arguments += $" {EmulatorExtraArguments}"; - } - - Log.LogMessage (MessageImportance.Normal, $"Starting: {emulatorPath} {arguments}"); - - try { - var psi = new ProcessStartInfo { - FileName = emulatorPath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - var process = new Process { StartInfo = psi }; - - // Capture output for diagnostics but don't block on it - process.OutputDataReceived += EmulatorOutputDataReceived; - process.ErrorDataReceived += EmulatorErrorDataReceived; - - process.Start (); - process.BeginOutputReadLine (); - process.BeginErrorReadLine (); - - return process; - } catch (Exception ex) { - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, avdName, ex.Message); - return null; - } - } - - void EmulatorOutputDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stdout: {e.Data}"); - } - } - - void EmulatorErrorDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stderr: {e.Data}"); - } - } - - /// - /// Polls 'adb devices' until a new emulator serial appears with state "device" (online). - /// Returns the serial or null on timeout / emulator process exit. - /// - string? WaitForEmulatorOnline (string adbPath, string avdName, Process emulatorProcess, Stopwatch stopwatch, TimeSpan timeout) - { - while (stopwatch.Elapsed < timeout) { - if (emulatorProcess.HasExited) { - return null; - } - - var serial = FindRunningEmulatorForAvd (adbPath, avdName); - if (serial != null) { - return serial; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - return null; - } - - /// - /// Waits for the emulator to fully boot by checking: - /// 1. sys.boot_completed property equals "1" - /// 2. Package manager is responsive (pm path android returns "package:") - /// - bool WaitForFullBoot (string adbPath, string avdName, string serial) - { - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to fully boot..."); - var stopwatch = Stopwatch.StartNew (); - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - - // Phase 1: Wait for sys.boot_completed == 1 - while (stopwatch.Elapsed < timeout) { - var bootCompleted = GetShellProperty (adbPath, serial, "sys.boot_completed"); - if (bootCompleted == "1") { - Log.LogMessage (MessageImportance.Normal, "sys.boot_completed = 1"); - break; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - if (stopwatch.Elapsed >= timeout) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - var remaining = timeout - stopwatch.Elapsed; - Log.LogMessage (MessageImportance.Normal, $"Phase 1 complete. {remaining.TotalSeconds:F0}s remaining for package manager."); - - // Phase 2: Wait for package manager to be responsive - while (stopwatch.Elapsed < timeout) { - var pmResult = RunShellCommand (adbPath, serial, "pm path android"); - if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.OrdinalIgnoreCase)) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' ({serial}) is fully booted and ready."); - return true; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - /// - /// Gets a system property from the device via 'adb -s serial shell getprop property'. - /// - protected virtual string? GetShellProperty (string adbPath, string serial, string propertyName) - { - string? value = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell getprop {propertyName}", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - value = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get property '{propertyName}' from {serial}: {ex.Message}"); - } - - return value; - } - - /// - /// Runs a shell command on the device and returns the first line of output. - /// - protected virtual string? RunShellCommand (string adbPath, string serial, string command) - { - string? result = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell {command}", - Log, - onOutput: (sender, e) => { - if (result == null && !e.Data.IsNullOrEmpty ()) { - result = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to run shell command '{command}' on {serial}: {ex.Message}"); - } - - return result; - } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 58ab1ae095e..6a482e471e5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -4,10 +4,13 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests; @@ -17,7 +20,7 @@ public class BootAndroidEmulatorTests : BaseTest List errors = []; List warnings = []; List messages = []; - MockBuildEngine engine = null!; + MockBuildEngine? engine; [SetUp] public void Setup () @@ -26,73 +29,30 @@ public void Setup () } /// - /// Mock version of BootAndroidEmulator that overrides all process-dependent methods - /// so we can test the task logic without launching real emulators or adb. + /// Mock version of BootAndroidEmulator that overrides + /// to return a configurable without launching real processes. /// class MockBootAndroidEmulator : BootAndroidEmulator { - public HashSet OnlineDevices { get; set; } = []; - public Dictionary RunningEmulatorAvdNames { get; set; } = new (); - public Dictionary EmulatorBootBehavior { get; set; } = new (); - public Dictionary BootCompletedValues { get; set; } = new (); - public Dictionary PmPathResults { get; set; } = new (); - public bool SimulateLaunchFailure { get; set; } - public string? LastLaunchAvdName { get; private set; } - - readonly Dictionary findCallCounts = new (); - - protected override bool IsOnlineAdbDevice (string adbPath, string deviceId) - => OnlineDevices.Contains (deviceId); - - protected override string? FindRunningEmulatorForAvd (string adbPath, string avdName) - { - foreach (var kvp in RunningEmulatorAvdNames) { - if (string.Equals (kvp.Value, avdName, StringComparison.OrdinalIgnoreCase) && - OnlineDevices.Contains (kvp.Key)) { - return kvp.Key; - } - } - - if (EmulatorBootBehavior.TryGetValue (avdName, out var behavior)) { - findCallCounts.TryAdd (avdName, 0); - findCallCounts [avdName]++; - if (findCallCounts [avdName] >= behavior.PollsUntilOnline) { - OnlineDevices.Add (behavior.Serial); - RunningEmulatorAvdNames [behavior.Serial] = avdName; - return behavior.Serial; - } - } - - return null; - } - - protected override string? GetRunningAvdName (string adbPath, string serial) - => RunningEmulatorAvdNames.TryGetValue (serial, out var name) ? name : null; - - protected override Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - LastLaunchAvdName = avdName; - - if (SimulateLaunchFailure) { - Log.LogError ("XA0143: Failed to launch emulator for AVD '{0}': {1}", avdName, "Simulated launch failure"); - return null; - } - - return Process.GetCurrentProcess (); - } - - protected override string? GetShellProperty (string adbPath, string serial, string propertyName) + public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; + public string? LastBootedDevice { get; private set; } + public EmulatorBootOptions? LastBootOptions { get; private set; } + public string? LastAdbPath { get; private set; } + public string? LastEmulatorPath { get; private set; } + + protected override Task ExecuteBootAsync ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options, + CancellationToken cancellationToken) { - if (propertyName == "sys.boot_completed" && BootCompletedValues.TryGetValue (serial, out var value)) - return value; - return null; - } - - protected override string? RunShellCommand (string adbPath, string serial, string command) - { - if (command == "pm path android" && PmPathResults.TryGetValue (serial, out var result)) - return result; - return null; + LastAdbPath = adbPath; + LastEmulatorPath = emulatorPath; + LastBootedDevice = device; + LastBootOptions = options; + return System.Threading.Tasks.Task.FromResult (BootResult); } } @@ -113,9 +73,12 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") public void AlreadyOnlineDevice_PassesThrough () { var task = CreateTask ("emulator-5554"); - task.OnlineDevices = ["emulator-5554"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual (0, errors.Count, "Should have no errors"); @@ -125,9 +88,12 @@ public void AlreadyOnlineDevice_PassesThrough () public void AlreadyOnlinePhysicalDevice_PassesThrough () { var task = CreateTask ("0A041FDD400327"); - task.OnlineDevices = ["0A041FDD400327"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "0A041FDD400327", + }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); Assert.AreEqual ("-s 0A041FDD400327", task.AdbTarget); } @@ -136,75 +102,58 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () public void AvdAlreadyRunning_WaitsForFullBoot () { var task = CreateTask ("Pixel_6_API_33"); - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - task.PmPathResults = new () { { "emulator-5554", "package:/system/framework/framework-res.apk" } }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void BootEmulator_AppearsAfterPolling () { var task = CreateTask ("Pixel_6_API_33"); - // Not online initially, will appear after 2 polls - task.EmulatorBootBehavior = new () { - { "Pixel_6_API_33", ("emulator-5556", 2) } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); - Assert.AreEqual ("Pixel_6_API_33", task.LastLaunchAvdName); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void LaunchFailure_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.SimulateLaunchFailure = true; - - Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Message != null && e.Message.Contains ("XA0143")), "Should have XA0143 error"); - Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); - } - - [Test] - public void BootTimeout_BootCompletedNeverReaches1 () - { - var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - // Emulator appears immediately but never finishes booting - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.LaunchFailed, + ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - task.BootCompletedValues = new () { { "emulator-5554", "0" } }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); + Assert.IsFalse (task.Execute (), "Task should fail"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } [Test] - public void BootTimeout_PmNeverResponds () + public void BootTimeout_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, + ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - // PmPathResults not set — pm never responds - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (task.Execute (), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); } @@ -212,15 +161,12 @@ public void BootTimeout_PmNeverResponds () public void MultipleEmulators_FindsCorrectAvd () { var task = CreateTask ("Pixel_9_Pro_XL"); - task.OnlineDevices = ["emulator-5554", "emulator-5556"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "pixel_7_-_api_35" }, - { "emulator-5556", "Pixel_9_Pro_XL" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); } @@ -233,12 +179,157 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () Device = "emulator-5554", AndroidSdkDirectory = "/android/sdk", BootTimeoutSeconds = 10, + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, }; - task.OnlineDevices = ["emulator-5554"]; - // Tool paths are not set explicitly — ResolveAdbPath/ResolveEmulatorPath - // should compute them from AndroidSdkDirectory - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + var adbPath = task.LastAdbPath?.Replace ('\\', '/'); + var emulatorPath = task.LastEmulatorPath?.Replace ('\\', '/'); + Assert.IsTrue (adbPath?.EndsWith ("platform-tools/adb") == true || adbPath?.EndsWith ("platform-tools/adb.exe") == true, + $"adb path should be resolved from AndroidSdkDirectory, got: {adbPath}"); + Assert.IsTrue (emulatorPath?.EndsWith ("emulator/emulator") == true || emulatorPath?.EndsWith ("emulator/emulator.exe") == true, + $"emulator path should be resolved from AndroidSdkDirectory, got: {emulatorPath}"); + } + + [Test] + public void ExtraArguments_PassedToOptions () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-no-snapshot-load -gpu auto", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed"); + Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions, "Boot options should be captured"); + Assert.IsNotNull (bootOptions.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-no-snapshot-load", "-gpu", "auto" }, + bootOptions.AdditionalArgs, + "Extra arguments should be parsed and passed to options"); + } + + [Test] + public void ExtraArguments_QuotedValuesPreserved () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-no-snapshot-load -skin \"Nexus 5X\"", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed"); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-no-snapshot-load", "-skin", "Nexus 5X" }, + bootOptions?.AdditionalArgs, + "Quoted arguments with spaces should be preserved as a single token"); + } + + [Test] + public void ExtraArguments_EscapedQuotesPreserved () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-prop \"persist.sys.timezone=\\\"America/New_York\\\"\"", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed"); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-prop", "persist.sys.timezone=\"America/New_York\"" }, + bootOptions?.AdditionalArgs, + "Escaped quotes inside quoted values should be preserved as literal quotes"); + } + + [Test] + public void UnknownError_MapsToXA0144 () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Unknown, + ErrorMessage = "Some unexpected error occurred", + }; + + Assert.IsFalse (task.Execute (), "Task should fail"); + var error = errors.FirstOrDefault (e => e.Code == "XA0144"); + Assert.IsNotNull (error, "Unknown errors should map to XA0144"); + StringAssert.Contains ("Unknown", error.Message, "Error kind should be included in the message"); + StringAssert.Contains ("Some unexpected error occurred", error.Message, "Error message should be included"); + } + + [Test] + public void Cancelled_DoesNotFail () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Cancelled, + }; + + Assert.IsTrue (task.Execute (), "Cancelled task should not fail — MSBuild handles cancellation"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null on cancellation"); + Assert.IsEmpty (errors, "No errors should be logged for cancellation"); + } + + [Test] + public void Success_NullSerial_ReturnsError () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = null, + }; + + Assert.IsFalse (task.Execute (), "Task should fail when serial is null"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Null serial should map to XA0143"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); + } + + [Test] + public void InvalidTimeout_ReturnsError () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootTimeoutSeconds = 0; + + Assert.IsFalse (task.Execute (), "Task should fail with zero timeout"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Invalid timeout should produce XA0145 error"); } }