diff --git a/Xamarin.MacDev/DeviceCtlOutputParser.cs b/Xamarin.MacDev/DeviceCtlOutputParser.cs new file mode 100644 index 0000000..19e1076 --- /dev/null +++ b/Xamarin.MacDev/DeviceCtlOutputParser.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Pure parsing of xcrun devicectl list devices JSON output into model objects. +/// JSON structure follows Apple's devicectl output format, validated against +/// parsing patterns from dotnet/macios GetAvailableDevices task. +/// +public static class DeviceCtlOutputParser { + + static readonly JsonDocumentOptions JsonOptions = new JsonDocumentOptions { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Parses the JSON output of xcrun devicectl list devices + /// into a list of . + /// + public static List ParseDevices (string? json, ICustomLogger? log = null) + { + var devices = new List (); + if (string.IsNullOrEmpty (json)) + return devices; + + try { + using (var doc = JsonDocument.Parse (json!, JsonOptions)) { + // Navigate to result.devices array + if (!doc.RootElement.TryGetProperty ("result", out var result)) + return devices; + if (!result.TryGetProperty ("devices", out var devicesArray)) + return devices; + if (devicesArray.ValueKind != JsonValueKind.Array) + return devices; + + foreach (var device in devicesArray.EnumerateArray ()) { + var info = new PhysicalDeviceInfo { + Identifier = GetString (device, "identifier"), + }; + + // deviceProperties + if (device.TryGetProperty ("deviceProperties", out var deviceProps)) { + info.Name = GetString (deviceProps, "name"); + info.BuildVersion = GetString (deviceProps, "osBuildUpdate"); + info.OSVersion = GetString (deviceProps, "osVersionNumber"); + } + + // hardwareProperties + if (device.TryGetProperty ("hardwareProperties", out var hwProps)) { + info.Udid = GetString (hwProps, "udid"); + info.DeviceClass = GetString (hwProps, "deviceType"); + info.HardwareModel = GetString (hwProps, "hardwareModel"); + info.Platform = GetString (hwProps, "platform"); + info.ProductType = GetString (hwProps, "productType"); + info.SerialNumber = GetString (hwProps, "serialNumber"); + + if (hwProps.TryGetProperty ("ecid", out var ecidElement)) { + if (ecidElement.TryGetUInt64 (out var ecid)) + info.UniqueChipID = ecid; + } + + // cpuType.name + if (hwProps.TryGetProperty ("cpuType", out var cpuType)) + info.CpuArchitecture = GetString (cpuType, "name"); + } + + // connectionProperties + if (device.TryGetProperty ("connectionProperties", out var connProps)) { + info.TransportType = GetString (connProps, "transportType"); + info.PairingState = GetString (connProps, "pairingState"); + } + + // Fallback: use identifier as UDID if hardware UDID is missing + if (string.IsNullOrEmpty (info.Udid)) + info.Udid = info.Identifier; + + devices.Add (info); + } + } + } catch (JsonException ex) { + log?.LogInfo ("DeviceCtlOutputParser.ParseDevices failed: {0}", ex.Message); + } catch (InvalidOperationException ex) { + log?.LogInfo ("DeviceCtlOutputParser.ParseDevices failed: {0}", ex.Message); + } + + return devices; + } + + static string GetString (JsonElement element, string property) + { + if (element.TryGetProperty (property, out var value)) { + if (value.ValueKind == JsonValueKind.Null || value.ValueKind == JsonValueKind.Undefined) + return ""; + if (value.ValueKind == JsonValueKind.String) + return value.GetString () ?? ""; + return value.ToString (); + } + return ""; + } +} diff --git a/Xamarin.MacDev/Models/PhysicalDeviceInfo.cs b/Xamarin.MacDev/Models/PhysicalDeviceInfo.cs new file mode 100644 index 0000000..18a67a8 --- /dev/null +++ b/Xamarin.MacDev/Models/PhysicalDeviceInfo.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev.Models; + +/// +/// Information about a physical Apple device from xcrun devicectl. +/// Corresponds to entries in the "result.devices" section of devicectl JSON. +/// +public class PhysicalDeviceInfo { + /// The device display name (e.g. "Rolf's iPhone 15"). + public string Name { get; set; } = ""; + + /// The device UDID. + public string Udid { get; set; } = ""; + + /// The device identifier (GUID from devicectl). + public string Identifier { get; set; } = ""; + + /// The OS build version (e.g. "23B85"). + public string BuildVersion { get; set; } = ""; + + /// The OS version number (e.g. "18.1"). + public string OSVersion { get; set; } = ""; + + /// The device class (e.g. "iPhone", "iPad", "appleWatch"). + public string DeviceClass { get; set; } = ""; + + /// The hardware model (e.g. "D83AP"). + public string HardwareModel { get; set; } = ""; + + /// The platform (e.g. "iOS", "watchOS"). + public string Platform { get; set; } = ""; + + /// The product type (e.g. "iPhone16,1"). + public string ProductType { get; set; } = ""; + + /// The serial number. + public string SerialNumber { get; set; } = ""; + + /// The ECID (unique chip identifier). + public ulong? UniqueChipID { get; set; } + + /// The CPU architecture (e.g. "arm64e"). + public string CpuArchitecture { get; set; } = ""; + + /// The connection transport type (e.g. "localNetwork"). + public string TransportType { get; set; } = ""; + + /// The pairing state (e.g. "paired"). + public string PairingState { get; set; } = ""; + + public override string ToString () => $"{Name} ({Udid}) [{DeviceClass}]"; +} diff --git a/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs b/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs index 2f521ea..596cc9b 100644 --- a/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs +++ b/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs @@ -7,6 +7,7 @@ namespace Xamarin.MacDev.Models { /// /// Information about a simulator device from xcrun simctl. + /// Fields aligned with dotnet/macios GetAvailableDevices task. /// public class SimulatorDeviceInfo { /// The simulator display name (e.g. "iPhone 16 Pro"). @@ -27,6 +28,15 @@ public class SimulatorDeviceInfo { /// Whether this simulator is available. public bool IsAvailable { get; set; } + /// Availability error message when IsAvailable is false. + public string AvailabilityError { get; set; } = ""; + + /// The runtime version for this device (e.g. "18.2"), derived from the runtime. + public string OSVersion { get; set; } = ""; + + /// The platform (e.g. "iOS", "tvOS"), derived from the runtime identifier. + public string Platform { get; set; } = ""; + public bool IsBooted => State == "Booted"; public override string ToString () => $"{Name} ({Udid}) [{State}]"; diff --git a/Xamarin.MacDev/Models/SimulatorDeviceTypeInfo.cs b/Xamarin.MacDev/Models/SimulatorDeviceTypeInfo.cs new file mode 100644 index 0000000..7ca0e84 --- /dev/null +++ b/Xamarin.MacDev/Models/SimulatorDeviceTypeInfo.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev.Models; + +/// +/// Information about a simulator device type from xcrun simctl. +/// Corresponds to entries in the "devicetypes" section of simctl JSON. +/// +public class SimulatorDeviceTypeInfo { + /// The device type identifier (e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"). + public string Identifier { get; set; } = ""; + + /// The display name (e.g. "iPhone 16 Pro"). + public string Name { get; set; } = ""; + + /// The product family (e.g. "iPhone", "iPad", "Apple TV"). + public string ProductFamily { get; set; } = ""; + + /// The minimum runtime version string (e.g. "13.0.0"). + public string MinRuntimeVersionString { get; set; } = ""; + + /// The maximum runtime version string (e.g. "65535.255.255"). + public string MaxRuntimeVersionString { get; set; } = ""; + + /// The model identifier (e.g. "iPhone12,1"). + public string ModelIdentifier { get; set; } = ""; + + public override string ToString () => $"{Name} ({Identifier})"; +} diff --git a/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs b/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs index 414f1b9..a3a5039 100644 --- a/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs +++ b/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs @@ -30,6 +30,9 @@ public class SimulatorRuntimeInfo { /// Whether this runtime is bundled with Xcode (vs downloaded separately). public bool IsBundled { get; set; } + /// CPU architectures supported by this runtime (e.g. "arm64", "x86_64"). + public System.Collections.Generic.List SupportedArchitectures { get; set; } = new System.Collections.Generic.List (); + public override string ToString () => $"{Name} ({Identifier})"; } } diff --git a/Xamarin.MacDev/SimCtl.cs b/Xamarin.MacDev/SimCtl.cs new file mode 100644 index 0000000..959fc76 --- /dev/null +++ b/Xamarin.MacDev/SimCtl.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Low-level wrapper for running xcrun simctl subcommands. +/// Shared by and RuntimeService +/// to avoid duplicated subprocess execution logic. +/// Logs all subprocess executions and their results. +/// +public class SimCtl { + + static readonly string XcrunPath = "/usr/bin/xcrun"; + + readonly ICustomLogger log; + + public SimCtl (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } + + /// + /// Runs xcrun simctl {args} and returns stdout, or null on failure. + /// All subprocess executions and errors are logged. + /// + public string? Run (params string [] args) + { + if (!File.Exists (XcrunPath)) { + log.LogInfo ("xcrun not found at '{0}'.", XcrunPath); + return null; + } + + var fullArgs = new string [args.Length + 1]; + fullArgs [0] = "simctl"; + Array.Copy (args, 0, fullArgs, 1, args.Length); + + log.LogInfo ("Executing: {0} {1}", XcrunPath, string.Join (" ", fullArgs)); + + try { + var (exitCode, stdout, stderr) = ProcessUtils.Exec (XcrunPath, fullArgs); + if (exitCode != 0) { + log.LogInfo ("simctl {0} failed (exit {1}): {2}", args.Length > 0 ? args [0] : "", exitCode, stderr.Trim ()); + return null; + } + return stdout; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcrun simctl: {0}", ex.Message); + return null; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcrun simctl: {0}", ex.Message); + return null; + } + } +} diff --git a/Xamarin.MacDev/SimctlOutputParser.cs b/Xamarin.MacDev/SimctlOutputParser.cs new file mode 100644 index 0000000..03f81d1 --- /dev/null +++ b/Xamarin.MacDev/SimctlOutputParser.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Pure parsing of xcrun simctl list JSON output into model objects. +/// JSON structure follows Apple's simctl output format, validated against +/// parsing patterns from ClientTools.Platform RemoteSimulatorValidator and +/// Redth/AppleDev.Tools SimCtl. +/// +public static class SimctlOutputParser { + + static readonly JsonDocumentOptions JsonOptions = new JsonDocumentOptions { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Parses the JSON output of xcrun simctl list devices --json + /// into a list of . + /// Device keys are runtime identifiers like + /// "com.apple.CoreSimulator.SimRuntime.iOS-18-2". + /// + public static List ParseDevices (string? json, ICustomLogger? log = null) + { + var devices = new List (); + if (string.IsNullOrEmpty (json)) + return devices; + + try { + using (var doc = JsonDocument.Parse (json!, JsonOptions)) { + if (!doc.RootElement.TryGetProperty ("devices", out var devicesElement)) + return devices; + + foreach (var runtimeProp in devicesElement.EnumerateObject ()) { + var runtimeId = runtimeProp.Name; + // Derive platform and version from runtime identifier + // e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-2" → platform="iOS", version="18.2" + var (platform, osVersion) = ParseRuntimeIdentifier (runtimeId); + + foreach (var device in runtimeProp.Value.EnumerateArray ()) { + var info = new SimulatorDeviceInfo { + RuntimeIdentifier = runtimeId, + Name = GetString (device, "name"), + Udid = GetString (device, "udid"), + State = GetString (device, "state"), + DeviceTypeIdentifier = GetString (device, "deviceTypeIdentifier"), + IsAvailable = GetBool (device, "isAvailable"), + AvailabilityError = GetString (device, "availabilityError"), + Platform = platform, + OSVersion = osVersion, + }; + + devices.Add (info); + } + } + } + } catch (JsonException ex) { + log?.LogInfo ("SimctlOutputParser.ParseDevices failed: {0}", ex.Message); + } catch (InvalidOperationException ex) { + log?.LogInfo ("SimctlOutputParser.ParseDevices failed: {0}", ex.Message); + } + + return devices; + } + + /// + /// Parses the JSON output of xcrun simctl list runtimes --json + /// into a list of . + /// + public static List ParseRuntimes (string? json, ICustomLogger? log = null) + { + var runtimes = new List (); + if (string.IsNullOrEmpty (json)) + return runtimes; + + try { + using (var doc = JsonDocument.Parse (json!, JsonOptions)) { + if (!doc.RootElement.TryGetProperty ("runtimes", out var runtimesArray)) + return runtimes; + + foreach (var rt in runtimesArray.EnumerateArray ()) { + var info = new SimulatorRuntimeInfo { + Name = GetString (rt, "name"), + Identifier = GetString (rt, "identifier"), + Version = GetString (rt, "version"), + BuildVersion = GetString (rt, "buildversion"), + Platform = GetString (rt, "platform"), + IsAvailable = GetBool (rt, "isAvailable"), + IsBundled = string.Equals (GetString (rt, "contentType"), "bundled", StringComparison.OrdinalIgnoreCase), + }; + + if (rt.TryGetProperty ("supportedArchitectures", out var archArray) && + archArray.ValueKind == JsonValueKind.Array) { + foreach (var arch in archArray.EnumerateArray ()) { + var a = arch.ValueKind == JsonValueKind.String ? arch.GetString () : null; + if (!string.IsNullOrEmpty (a)) + info.SupportedArchitectures.Add (a!); + } + } + + runtimes.Add (info); + } + } + } catch (JsonException ex) { + log?.LogInfo ("SimctlOutputParser.ParseRuntimes failed: {0}", ex.Message); + } catch (InvalidOperationException ex) { + log?.LogInfo ("SimctlOutputParser.ParseRuntimes failed: {0}", ex.Message); + } + + return runtimes; + } + + /// + /// Parses the JSON output of xcrun simctl list devicetypes --json + /// into a list of . + /// + public static List ParseDeviceTypes (string? json, ICustomLogger? log = null) + { + var deviceTypes = new List (); + if (string.IsNullOrEmpty (json)) + return deviceTypes; + + try { + using (var doc = JsonDocument.Parse (json!, JsonOptions)) { + if (!doc.RootElement.TryGetProperty ("devicetypes", out var dtArray)) + return deviceTypes; + + foreach (var dt in dtArray.EnumerateArray ()) { + var info = new SimulatorDeviceTypeInfo { + Identifier = GetString (dt, "identifier"), + Name = GetString (dt, "name"), + ProductFamily = GetString (dt, "productFamily"), + MinRuntimeVersionString = GetString (dt, "minRuntimeVersionString"), + MaxRuntimeVersionString = GetString (dt, "maxRuntimeVersionString"), + ModelIdentifier = GetString (dt, "modelIdentifier"), + }; + + deviceTypes.Add (info); + } + } + } catch (JsonException ex) { + log?.LogInfo ("SimctlOutputParser.ParseDeviceTypes failed: {0}", ex.Message); + } catch (InvalidOperationException ex) { + log?.LogInfo ("SimctlOutputParser.ParseDeviceTypes failed: {0}", ex.Message); + } + + return deviceTypes; + } + + /// + /// Parses the UDID from the output of xcrun simctl create. + /// The command outputs just the UDID on a single line. + /// + public static string? ParseCreateOutput (string? output) + { + if (string.IsNullOrEmpty (output)) + return null; + + var udid = output!.Trim (); + return udid.Length > 0 ? udid : null; + } + + static string GetString (JsonElement element, string property) + { + if (element.TryGetProperty (property, out var value)) { + if (value.ValueKind == JsonValueKind.Null || value.ValueKind == JsonValueKind.Undefined) + return ""; + if (value.ValueKind == JsonValueKind.String) + return value.GetString () ?? ""; + return value.ToString (); + } + return ""; + } + + static bool GetBool (JsonElement element, string property) + { + if (element.TryGetProperty (property, out var value)) { + if (value.ValueKind == JsonValueKind.True) + return true; + if (value.ValueKind == JsonValueKind.False) + return false; + // simctl sometimes returns "true"/"false" as strings + if (value.ValueKind == JsonValueKind.String) + return string.Equals (value.GetString (), "true", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + /// + /// Parses a runtime identifier like "com.apple.CoreSimulator.SimRuntime.iOS-18-2" + /// into a (platform, version) tuple e.g. ("iOS", "18.2"). + /// Pattern from dotnet/macios GetAvailableDevices. + /// + public static (string platform, string version) ParseRuntimeIdentifier (string identifier) + { + if (string.IsNullOrEmpty (identifier)) + return ("", ""); + + // Strip prefix "com.apple.CoreSimulator.SimRuntime." + const string prefix = "com.apple.CoreSimulator.SimRuntime."; + var name = identifier.StartsWith (prefix, StringComparison.Ordinal) + ? identifier.Substring (prefix.Length) + : identifier; + + // Split "iOS-18-2" → ["iOS", "18", "2"] + var parts = name.Split ('-'); + if (parts.Length < 2) + return (name, ""); + + var platform = parts [0]; + var version = string.Join (".", parts, 1, parts.Length - 1); + return (platform, version); + } +} diff --git a/Xamarin.MacDev/SimulatorService.cs b/Xamarin.MacDev/SimulatorService.cs new file mode 100644 index 0000000..2a9624a --- /dev/null +++ b/Xamarin.MacDev/SimulatorService.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// High-level simulator operations wrapping xcrun simctl. +/// Follows the instance-based pattern. +/// Operation patterns validated against Redth/AppleDev.Tools SimCtl and +/// ClientTools.Platform RemoteSimulatorValidator. +/// +public class SimulatorService { + + readonly ICustomLogger log; + readonly SimCtl simctl; + + public SimulatorService (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + simctl = new SimCtl (log); + } + + /// + /// Lists all simulator devices. Optionally filters by availability. + /// + public List List (bool availableOnly = false) + { + var json = simctl.Run ("list", "devices", "--json"); + if (json is null) + return new List (); + + var devices = SimctlOutputParser.ParseDevices (json, log); + + if (availableOnly) + devices.RemoveAll (d => !d.IsAvailable); + + log.LogInfo ("Found {0} simulator device(s).", devices.Count); + return devices; + } + + /// + /// Creates a new simulator device. Returns the UDID of the created device, or null on failure. + /// Pattern from ClientTools.Platform: xcrun simctl create "name" "deviceTypeId" + /// + public string? Create (string name, string deviceTypeIdentifier, string? runtimeIdentifier = null) + { + if (string.IsNullOrEmpty (name)) + throw new ArgumentException ("Name must not be null or empty.", nameof (name)); + if (string.IsNullOrEmpty (deviceTypeIdentifier)) + throw new ArgumentException ("Device type identifier must not be null or empty.", nameof (deviceTypeIdentifier)); + + string? output; + if (!string.IsNullOrEmpty (runtimeIdentifier)) + output = simctl.Run ("create", name, deviceTypeIdentifier, runtimeIdentifier!); + else + output = simctl.Run ("create", name, deviceTypeIdentifier); + + if (output is null) + return null; + + var udid = SimctlOutputParser.ParseCreateOutput (output); + if (udid is not null) + log.LogInfo ("Created simulator '{0}' with UDID {1}.", name, udid); + else + log.LogInfo ("Failed to create simulator '{0}'.", name); + + return udid; + } + + /// + /// Boots a simulator device. + /// + public bool Boot (string udidOrName) + { + return RunSimctlBool ("boot", udidOrName); + } + + /// + /// Shuts down a simulator device. Pass "all" to shut down all simulators. + /// + public bool Shutdown (string udidOrName) + { + return RunSimctlBool ("shutdown", udidOrName); + } + + /// + /// Erases (factory resets) a simulator device. Pass "all" to erase all. + /// Pattern from Redth/AppleDev.Tools SimCtl.EraseAsync. + /// + public bool Erase (string udidOrName) + { + return RunSimctlBool ("erase", udidOrName); + } + + /// + /// Deletes a simulator device. Pass "unavailable" to delete unavailable sims, + /// or "all" to delete all. + /// + public bool Delete (string udidOrName) + { + return RunSimctlBool ("delete", udidOrName); + } + + bool RunSimctlBool (string subcommand, string target) + { + var result = simctl.Run (subcommand, target); + var success = result is not null; + if (success) + log.LogInfo ("simctl {0} '{1}' succeeded.", subcommand, target); + return success; + } +} diff --git a/Xamarin.MacDev/Xamarin.MacDev.csproj b/Xamarin.MacDev/Xamarin.MacDev.csproj index cc10fbd..e880c69 100644 --- a/Xamarin.MacDev/Xamarin.MacDev.csproj +++ b/Xamarin.MacDev/Xamarin.MacDev.csproj @@ -45,6 +45,7 @@ all + diff --git a/tests/DeviceCtlOutputParserTests.cs b/tests/DeviceCtlOutputParserTests.cs new file mode 100644 index 0000000..ee84b80 --- /dev/null +++ b/tests/DeviceCtlOutputParserTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using NUnit.Framework; +using Xamarin.MacDev; + +namespace Xamarin.MacDev.Tests; + +[TestFixture] +public class DeviceCtlOutputParserTests { + + [Test] + public void ParseDevices_ValidJson_ParsesAllFields () + { + var json = @"{ + ""result"": { + ""devices"": [ + { + ""connectionProperties"": { + ""pairingState"": ""paired"", + ""transportType"": ""localNetwork"" + }, + ""deviceProperties"": { + ""name"": ""Rolf's iPhone 15"", + ""osBuildUpdate"": ""23B85"", + ""osVersionNumber"": ""18.1"" + }, + ""hardwareProperties"": { + ""cpuType"": { ""name"": ""arm64e"" }, + ""deviceType"": ""iPhone"", + ""ecid"": 12345678, + ""hardwareModel"": ""D83AP"", + ""platform"": ""iOS"", + ""productType"": ""iPhone16,1"", + ""serialNumber"": ""SERIAL_1"", + ""udid"": ""00008003-012301230123ABCD"" + }, + ""identifier"": ""33333333-AAAA-BBBB-CCCC-DDDDDDDDDDDD"" + } + ] + } + }"; + + var result = DeviceCtlOutputParser.ParseDevices (json); + Assert.That (result.Count, Is.EqualTo (1)); + + var device = result [0]; + Assert.That (device.Name, Is.EqualTo ("Rolf's iPhone 15")); + Assert.That (device.Udid, Is.EqualTo ("00008003-012301230123ABCD")); + Assert.That (device.Identifier, Is.EqualTo ("33333333-AAAA-BBBB-CCCC-DDDDDDDDDDDD")); + Assert.That (device.BuildVersion, Is.EqualTo ("23B85")); + Assert.That (device.OSVersion, Is.EqualTo ("18.1")); + Assert.That (device.DeviceClass, Is.EqualTo ("iPhone")); + Assert.That (device.HardwareModel, Is.EqualTo ("D83AP")); + Assert.That (device.Platform, Is.EqualTo ("iOS")); + Assert.That (device.ProductType, Is.EqualTo ("iPhone16,1")); + Assert.That (device.SerialNumber, Is.EqualTo ("SERIAL_1")); + Assert.That (device.UniqueChipID, Is.EqualTo ((ulong) 12345678)); + Assert.That (device.CpuArchitecture, Is.EqualTo ("arm64e")); + Assert.That (device.TransportType, Is.EqualTo ("localNetwork")); + Assert.That (device.PairingState, Is.EqualTo ("paired")); + } + + [Test] + public void ParseDevices_MultipleDevices_ParsesAll () + { + var json = @"{ + ""result"": { + ""devices"": [ + { + ""deviceProperties"": { ""name"": ""iPad Pro"", ""osVersionNumber"": ""26.0"" }, + ""hardwareProperties"": { ""deviceType"": ""iPad"", ""platform"": ""iOS"", ""udid"": ""UDID-1"" }, + ""identifier"": ""ID-1"" + }, + { + ""deviceProperties"": { ""name"": ""Apple Watch"", ""osVersionNumber"": ""11.5"" }, + ""hardwareProperties"": { ""deviceType"": ""appleWatch"", ""platform"": ""watchOS"", ""udid"": ""UDID-2"" }, + ""identifier"": ""ID-2"" + } + ] + } + }"; + + var result = DeviceCtlOutputParser.ParseDevices (json); + Assert.That (result.Count, Is.EqualTo (2)); + Assert.That (result [0].Name, Is.EqualTo ("iPad Pro")); + Assert.That (result [0].DeviceClass, Is.EqualTo ("iPad")); + Assert.That (result [1].Name, Is.EqualTo ("Apple Watch")); + Assert.That (result [1].Platform, Is.EqualTo ("watchOS")); + } + + [Test] + public void ParseDevices_MissingUdid_FallsBackToIdentifier () + { + var json = @"{ + ""result"": { + ""devices"": [ + { + ""deviceProperties"": { ""name"": ""Mac"" }, + ""hardwareProperties"": { ""deviceType"": ""mac"", ""platform"": ""macOS"" }, + ""identifier"": ""12345678-1234-1234-ABCD-1234567980AB"" + } + ] + } + }"; + + var result = DeviceCtlOutputParser.ParseDevices (json); + Assert.That (result.Count, Is.EqualTo (1)); + Assert.That (result [0].Udid, Is.EqualTo ("12345678-1234-1234-ABCD-1234567980AB")); + } + + [Test] + public void ParseDevices_EmptyJson_ReturnsEmptyList () + { + Assert.That (DeviceCtlOutputParser.ParseDevices (null).Count, Is.EqualTo (0)); + Assert.That (DeviceCtlOutputParser.ParseDevices ("").Count, Is.EqualTo (0)); + Assert.That (DeviceCtlOutputParser.ParseDevices ("{}").Count, Is.EqualTo (0)); + Assert.That (DeviceCtlOutputParser.ParseDevices ("{\"result\":{}}").Count, Is.EqualTo (0)); + Assert.That (DeviceCtlOutputParser.ParseDevices ("{\"result\":{\"devices\":[]}}").Count, Is.EqualTo (0)); + } + + [Test] + public void ParseDevices_LargeEcid_ParsesCorrectly () + { + var json = @"{ + ""result"": { + ""devices"": [ + { + ""deviceProperties"": { ""name"": ""Device"" }, + ""hardwareProperties"": { + ""ecid"": 18446744073709551615, + ""udid"": ""UDID-X"" + }, + ""identifier"": ""ID-X"" + } + ] + } + }"; + + var result = DeviceCtlOutputParser.ParseDevices (json); + Assert.That (result [0].UniqueChipID, Is.EqualTo (ulong.MaxValue)); + } + + [Test] + public void ParseDevices_MissingConnectionProperties_DefaultsToEmpty () + { + var json = @"{ + ""result"": { + ""devices"": [ + { + ""deviceProperties"": { ""name"": ""Device"" }, + ""hardwareProperties"": { ""udid"": ""U1"" }, + ""identifier"": ""I1"" + } + ] + } + }"; + + var result = DeviceCtlOutputParser.ParseDevices (json); + Assert.That (result [0].TransportType, Is.EqualTo ("")); + Assert.That (result [0].PairingState, Is.EqualTo ("")); + } + + [Test] + public void ParseDevices_MalformedJson_ReturnsPartialResults () + { + var result = DeviceCtlOutputParser.ParseDevices ("{ not valid json"); + Assert.That (result.Count, Is.EqualTo (0)); + } +} diff --git a/tests/SimctlOutputParserTests.cs b/tests/SimctlOutputParserTests.cs new file mode 100644 index 0000000..3f113d5 --- /dev/null +++ b/tests/SimctlOutputParserTests.cs @@ -0,0 +1,423 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using NUnit.Framework; +using Xamarin.MacDev; + +#nullable enable + +namespace Tests; + +[TestFixture] +public class SimctlOutputParserTests { + + // Realistic simctl list devices --json output based on actual Apple format + // Structure validated against ClientTools.Platform RemoteSimulatorValidator + static readonly string SampleDevicesJson = @"{ + ""devices"" : { + ""com.apple.CoreSimulator.SimRuntime.iOS-18-2"" : [ + { + ""name"" : ""iPhone 16 Pro"", + ""udid"" : ""A1B2C3D4-E5F6-7890-ABCD-EF1234567890"", + ""state"" : ""Shutdown"", + ""isAvailable"" : true, + ""deviceTypeIdentifier"" : ""com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"" + }, + { + ""name"" : ""iPhone 16"", + ""udid"" : ""B2C3D4E5-F6A7-8901-BCDE-F12345678901"", + ""state"" : ""Booted"", + ""isAvailable"" : true, + ""deviceTypeIdentifier"" : ""com.apple.CoreSimulator.SimDeviceType.iPhone-16"" + } + ], + ""com.apple.CoreSimulator.SimRuntime.tvOS-18-2"" : [ + { + ""name"" : ""Apple TV"", + ""udid"" : ""C3D4E5F6-A7B8-9012-CDEF-123456789012"", + ""state"" : ""Shutdown"", + ""isAvailable"" : false, + ""deviceTypeIdentifier"" : ""com.apple.CoreSimulator.SimDeviceType.Apple-TV-1080p"" + } + ] + } +}"; + + static readonly string SampleRuntimesJson = @"{ + ""runtimes"" : [ + { + ""bundlePath"" : ""/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime"", + ""buildversion"" : ""21F79"", + ""platform"" : ""iOS"", + ""runtimeRoot"" : ""/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot"", + ""identifier"" : ""com.apple.CoreSimulator.SimRuntime.iOS-17-5"", + ""version"" : ""17.5"", + ""contentType"" : ""diskImage"", + ""isAvailable"" : true, + ""name"" : ""iOS 17.5"", + ""supportedDeviceTypes"" : [] + }, + { + ""bundlePath"" : ""/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime"", + ""buildversion"" : ""22C150"", + ""platform"" : ""iOS"", + ""runtimeRoot"" : ""/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot"", + ""identifier"" : ""com.apple.CoreSimulator.SimRuntime.iOS-18-2"", + ""version"" : ""18.2"", + ""contentType"" : ""bundled"", + ""isAvailable"" : true, + ""name"" : ""iOS 18.2"", + ""supportedDeviceTypes"" : [] + }, + { + ""bundlePath"" : ""/Applications/Xcode.app/Contents/Developer/Platforms/AppleTVOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/tvOS.simruntime"", + ""buildversion"" : ""22K150"", + ""platform"" : ""tvOS"", + ""runtimeRoot"" : ""/path/to/runtime"", + ""identifier"" : ""com.apple.CoreSimulator.SimRuntime.tvOS-18-2"", + ""version"" : ""18.2"", + ""contentType"" : ""bundled"", + ""isAvailable"" : false, + ""name"" : ""tvOS 18.2"", + ""supportedDeviceTypes"" : [] + } + ] +}"; + + [Test] + public void ParseDevices_ParsesMultipleRuntimes () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + Assert.That (devices.Count, Is.EqualTo (3)); + } + + [Test] + public void ParseDevices_SetsRuntimeIdentifier () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + Assert.That (devices [0].RuntimeIdentifier, Is.EqualTo ("com.apple.CoreSimulator.SimRuntime.iOS-18-2")); + Assert.That (devices [2].RuntimeIdentifier, Is.EqualTo ("com.apple.CoreSimulator.SimRuntime.tvOS-18-2")); + } + + [Test] + public void ParseDevices_SetsDeviceProperties () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + var iphone16Pro = devices [0]; + Assert.That (iphone16Pro.Name, Is.EqualTo ("iPhone 16 Pro")); + Assert.That (iphone16Pro.Udid, Is.EqualTo ("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")); + Assert.That (iphone16Pro.State, Is.EqualTo ("Shutdown")); + Assert.That (iphone16Pro.IsAvailable, Is.True); + Assert.That (iphone16Pro.DeviceTypeIdentifier, Is.EqualTo ("com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro")); + Assert.That (iphone16Pro.IsBooted, Is.False); + } + + [Test] + public void ParseDevices_DetectsBootedState () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + Assert.That (devices [1].IsBooted, Is.True); + Assert.That (devices [1].State, Is.EqualTo ("Booted")); + } + + [Test] + public void ParseDevices_DetectsUnavailableDevices () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + Assert.That (devices [2].IsAvailable, Is.False); + } + + [Test] + public void ParseDevices_ReturnsEmptyForNullOrEmpty () + { + Assert.That (SimctlOutputParser.ParseDevices (""), Is.Empty); + Assert.That (SimctlOutputParser.ParseDevices (null!), Is.Empty); + } + + [Test] + public void ParseDevices_ReturnsEmptyForNoDevicesKey () + { + Assert.That (SimctlOutputParser.ParseDevices ("{}"), Is.Empty); + } + + [Test] + public void ParseRuntimes_ParsesMultipleRuntimes () + { + var runtimes = SimctlOutputParser.ParseRuntimes (SampleRuntimesJson); + Assert.That (runtimes.Count, Is.EqualTo (3)); + } + + [Test] + public void ParseRuntimes_SetsRuntimeProperties () + { + var runtimes = SimctlOutputParser.ParseRuntimes (SampleRuntimesJson); + var ios175 = runtimes [0]; + Assert.That (ios175.Name, Is.EqualTo ("iOS 17.5")); + Assert.That (ios175.Identifier, Is.EqualTo ("com.apple.CoreSimulator.SimRuntime.iOS-17-5")); + Assert.That (ios175.Version, Is.EqualTo ("17.5")); + Assert.That (ios175.BuildVersion, Is.EqualTo ("21F79")); + Assert.That (ios175.Platform, Is.EqualTo ("iOS")); + Assert.That (ios175.IsAvailable, Is.True); + Assert.That (ios175.IsBundled, Is.False); + } + + [Test] + public void ParseRuntimes_DetectsBundledRuntime () + { + var runtimes = SimctlOutputParser.ParseRuntimes (SampleRuntimesJson); + Assert.That (runtimes [0].IsBundled, Is.False); + Assert.That (runtimes [1].IsBundled, Is.True); + } + + [Test] + public void ParseRuntimes_DetectsUnavailableRuntime () + { + var runtimes = SimctlOutputParser.ParseRuntimes (SampleRuntimesJson); + Assert.That (runtimes [2].IsAvailable, Is.False); + Assert.That (runtimes [2].Platform, Is.EqualTo ("tvOS")); + } + + [Test] + public void ParseRuntimes_ReturnsEmptyForNullOrEmpty () + { + Assert.That (SimctlOutputParser.ParseRuntimes (""), Is.Empty); + Assert.That (SimctlOutputParser.ParseRuntimes (null!), Is.Empty); + } + + [Test] + public void ParseRuntimes_ReturnsEmptyForNoRuntimesKey () + { + Assert.That (SimctlOutputParser.ParseRuntimes ("{}"), Is.Empty); + } + + [Test] + public void ParseCreateOutput_ReturnsUdid () + { + Assert.That (SimctlOutputParser.ParseCreateOutput ("A1B2C3D4-E5F6-7890-ABCD-EF1234567890\n"), + Is.EqualTo ("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")); + } + + [Test] + public void ParseCreateOutput_ReturnsNullForEmpty () + { + Assert.That (SimctlOutputParser.ParseCreateOutput (""), Is.Null); + Assert.That (SimctlOutputParser.ParseCreateOutput (null!), Is.Null); + } + + [Test] + public void ParseDevices_HandlesBoolAsString () + { + // simctl sometimes returns isAvailable as a string (observed in + // Redth/AppleDev.Tools FlexibleStringConverter) + var json = @"{ + ""devices"" : { + ""com.apple.CoreSimulator.SimRuntime.iOS-17-0"" : [ + { + ""name"" : ""iPhone 15"", + ""udid"" : ""12345"", + ""state"" : ""Shutdown"", + ""isAvailable"" : ""true"", + ""deviceTypeIdentifier"" : ""com.apple.CoreSimulator.SimDeviceType.iPhone-15"" + } + ] + } +}"; + var devices = SimctlOutputParser.ParseDevices (json); + // isAvailable as string "true" won't match JsonValueKind.True, + // but our GetBool handles string fallback + Assert.That (devices.Count, Is.EqualTo (1)); + Assert.That (devices [0].IsAvailable, Is.True); + } + + [Test] + public void ParseDevices_DerivesAvailabilityError () + { + var json = @"{ + ""devices"" : { + ""com.apple.CoreSimulator.SimRuntime.iOS-26-0"" : [ + { + ""udid"" : ""D4D95709"", + ""isAvailable"" : false, + ""availabilityError"" : ""runtime profile not found"", + ""deviceTypeIdentifier"" : ""com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro"", + ""state"" : ""Shutdown"", + ""name"" : ""iPhone 17 Pro"" + } + ] + } +}"; + var devices = SimctlOutputParser.ParseDevices (json); + Assert.That (devices.Count, Is.EqualTo (1)); + Assert.That (devices [0].IsAvailable, Is.False); + Assert.That (devices [0].AvailabilityError, Does.Contain ("runtime profile not found")); + Assert.That (devices [0].Platform, Is.EqualTo ("iOS")); + Assert.That (devices [0].OSVersion, Is.EqualTo ("26.0")); + } + + [Test] + public void ParseDevices_DerivesPlatformAndVersion () + { + var devices = SimctlOutputParser.ParseDevices (SampleDevicesJson); + Assert.That (devices [0].Platform, Is.EqualTo ("iOS")); + Assert.That (devices [0].OSVersion, Is.EqualTo ("18.2")); + } + + [Test] + public void ParseRuntimes_ParsesSupportedArchitectures () + { + var json = @"{ + ""runtimes"" : [ + { + ""identifier"" : ""com.apple.CoreSimulator.SimRuntime.iOS-26-1"", + ""version"" : ""26.1"", + ""platform"" : ""iOS"", + ""isAvailable"" : true, + ""name"" : ""iOS 26.1"", + ""buildversion"" : ""23J579"", + ""supportedArchitectures"" : [ ""arm64"" ] + } + ] +}"; + var runtimes = SimctlOutputParser.ParseRuntimes (json); + Assert.That (runtimes.Count, Is.EqualTo (1)); + Assert.That (runtimes [0].SupportedArchitectures, Has.Count.EqualTo (1)); + Assert.That (runtimes [0].SupportedArchitectures [0], Is.EqualTo ("arm64")); + } + + [TestCase ("com.apple.CoreSimulator.SimRuntime.iOS-18-2", "iOS", "18.2")] + [TestCase ("com.apple.CoreSimulator.SimRuntime.tvOS-26-1", "tvOS", "26.1")] + [TestCase ("com.apple.CoreSimulator.SimRuntime.watchOS-11-0", "watchOS", "11.0")] + [TestCase ("", "", "")] + public void ParseRuntimeIdentifier_ExtractsPlatformAndVersion (string identifier, string expectedPlatform, string expectedVersion) + { + var (platform, version) = SimctlOutputParser.ParseRuntimeIdentifier (identifier); + Assert.That (platform, Is.EqualTo (expectedPlatform)); + Assert.That (version, Is.EqualTo (expectedVersion)); + } + + [Test] + public void ParseDeviceTypes_ValidJson_ParsesAllFields () + { + var json = @"{ + ""devicetypes"": [ + { + ""productFamily"": ""iPhone"", + ""identifier"": ""com.apple.CoreSimulator.SimDeviceType.iPhone-11"", + ""modelIdentifier"": ""iPhone12,1"", + ""minRuntimeVersionString"": ""13.0.0"", + ""maxRuntimeVersionString"": ""65535.255.255"", + ""name"": ""iPhone 11"" + }, + { + ""productFamily"": ""iPad"", + ""identifier"": ""com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-12GB"", + ""modelIdentifier"": ""iPad17,4"", + ""minRuntimeVersionString"": ""26.0.0"", + ""maxRuntimeVersionString"": ""65535.255.255"", + ""name"": ""iPad Pro 13-inch (M5)"" + }, + { + ""productFamily"": ""Apple TV"", + ""identifier"": ""com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K"", + ""modelIdentifier"": ""AppleTV14,1"", + ""minRuntimeVersionString"": ""16.1.0"", + ""maxRuntimeVersionString"": ""65535.255.255"", + ""name"": ""Apple TV 4K (3rd generation)"" + } + ] + }"; + + var result = SimctlOutputParser.ParseDeviceTypes (json); + Assert.That (result.Count, Is.EqualTo (3)); + + Assert.That (result [0].Identifier, Is.EqualTo ("com.apple.CoreSimulator.SimDeviceType.iPhone-11")); + Assert.That (result [0].Name, Is.EqualTo ("iPhone 11")); + Assert.That (result [0].ProductFamily, Is.EqualTo ("iPhone")); + Assert.That (result [0].MinRuntimeVersionString, Is.EqualTo ("13.0.0")); + Assert.That (result [0].MaxRuntimeVersionString, Is.EqualTo ("65535.255.255")); + Assert.That (result [0].ModelIdentifier, Is.EqualTo ("iPhone12,1")); + + Assert.That (result [1].ProductFamily, Is.EqualTo ("iPad")); + Assert.That (result [1].Name, Is.EqualTo ("iPad Pro 13-inch (M5)")); + Assert.That (result [1].MinRuntimeVersionString, Is.EqualTo ("26.0.0")); + + Assert.That (result [2].ProductFamily, Is.EqualTo ("Apple TV")); + Assert.That (result [2].Name, Is.EqualTo ("Apple TV 4K (3rd generation)")); + } + + [Test] + public void ParseDeviceTypes_EmptyJson_ReturnsEmptyList () + { + Assert.That (SimctlOutputParser.ParseDeviceTypes (null).Count, Is.EqualTo (0)); + Assert.That (SimctlOutputParser.ParseDeviceTypes ("").Count, Is.EqualTo (0)); + Assert.That (SimctlOutputParser.ParseDeviceTypes ("{}").Count, Is.EqualTo (0)); + } + + [Test] + public void ParseDeviceTypes_MissingFields_ReturnsDefaults () + { + var json = @"{ + ""devicetypes"": [ + { + ""identifier"": ""com.apple.CoreSimulator.SimDeviceType.iPhone-X"" + } + ] + }"; + var result = SimctlOutputParser.ParseDeviceTypes (json); + Assert.That (result.Count, Is.EqualTo (1)); + Assert.That (result [0].Identifier, Is.EqualTo ("com.apple.CoreSimulator.SimDeviceType.iPhone-X")); + Assert.That (result [0].Name, Is.EqualTo ("")); + Assert.That (result [0].ProductFamily, Is.EqualTo ("")); + Assert.That (result [0].MinRuntimeVersionString, Is.EqualTo ("")); + } + + [Test] + [Platform ("MacOsX")] + public void LiveSimctlList_ParsesWithoutExceptions () + { + // Run actual simctl list on the machine and verify parsing succeeds + // with no exceptions logged — per rolfbjarne review feedback + var logger = new TestLogger (); + var simctl = new SimCtl (logger); + var json = simctl.Run ("list", "--json"); + Assert.That (json, Is.Not.Null, "simctl list --json should return output"); + + var devices = SimctlOutputParser.ParseDevices (json, logger); + var runtimes = SimctlOutputParser.ParseRuntimes (json, logger); + var deviceTypes = SimctlOutputParser.ParseDeviceTypes (json, logger); + + Assert.That (devices, Is.Not.Null, "Devices list should not be null"); + Assert.That (runtimes, Is.Not.Null, "Runtimes list should not be null"); + Assert.That (deviceTypes, Is.Not.Null, "Device types list should not be null"); + Assert.That (logger.Errors, Is.Empty, "No errors should be logged during parsing: " + string.Join ("; ", logger.Errors)); + } + + /// + /// Test logger that captures error/warning messages for assertion. + /// + class TestLogger : ICustomLogger { + public System.Collections.Generic.List Errors { get; } = new System.Collections.Generic.List (); + + public void LogError (string message, System.Exception? ex) + { + Errors.Add (ex is null ? message : $"{message}: {ex.Message}"); + } + + public void LogWarning (string messageFormat, params object? [] args) + { + var msg = string.Format (messageFormat, args); + if (msg.Contains ("failed:") || msg.Contains ("Could not")) + Errors.Add (msg); + } + + public void LogInfo (string messageFormat, params object? [] args) + { + var msg = string.Format (messageFormat, args); + if (msg.Contains ("failed:") || msg.Contains ("Could not")) + Errors.Add (msg); + } + + public void LogDebug (string messageFormat, params object? [] args) { } + } +} +