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) { }
+ }
+}
+